diff --git a/docs/CODE_SIMULATION.md b/docs/CODE_SIMULATION.md index b671f2635c..a1f90b3451 100644 --- a/docs/CODE_SIMULATION.md +++ b/docs/CODE_SIMULATION.md @@ -24,3 +24,30 @@ You should see updates both in Fission and in the readouts in the code simulator The robot code will control the movement of the robot in Fission, and you can expand each of the devices in the robot code simulator GUI to see if their fields are being updated by Fission. For example, running the JavaAutoSample should cause the `ADXL362[4]` and `SYN AHRS[0]` devices to update with rotation and acceleration data from Fission (note that the names may differ if the code changes). + +## FTC Code Simulation + +To run: + +``` +cd simulation/SyntheSimFTC +./gradlew run --args="--src [--opmode ] [--port ]" +``` + +`--src` points at a plain directory of `.java` files, rooted so package folders (`org/firstinspires/ftc/teamcode/...`) hang off it. `--port` defaults to `3301`. Make sure to start the simulation before starting the dev server. + +`--opmode` picks which discovered OpMode to run, matching a class name (`ExampleDozerArcadeDrive`), or the annotation's display name (`"Dozer Arcade Drive"`). + +The runner logs every OpMode it found and which one it selected, e.g.: + +``` +[OpModeRunner] Discovered [TeleOp] Dozer Arcade Drive (org.firstinspires.ftc.teamcode.examples.ExampleDozerArcadeDrive) +[OpModeRunner] Running [TeleOp] Dozer Arcade Drive (org.firstinspires.ftc.teamcode.examples.ExampleDozerArcadeDrive) +``` + +Running the Dozer sample at `simulation/samples/FTCDozerArcadeDriveSample`: + +``` +cd simulation/SyntheSimFTC +./gradlew run --args="--src ../samples/FTCDozerArcadeDriveSample --opmode ExampleDozerArcadeDrive" +``` diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index 0c444be828..17cca9e28a 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -250,7 +250,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { this.nameOverride ?? (this._brain?.isSynthesis() ? this._brain.inputSchemeName - : this._brain?.isWPILib() + : this._brain?.isWPILib() || this._brain?.isFTC() ? "Magic" : "Not Configured!") if (World.multiplayerSystem != null) { diff --git a/fission/src/systems/simulation/Brain.ts b/fission/src/systems/simulation/Brain.ts index 93bcce8efd..5ca4f3ac56 100644 --- a/fission/src/systems/simulation/Brain.ts +++ b/fission/src/systems/simulation/Brain.ts @@ -2,7 +2,7 @@ import type Mechanism from "../physics/Mechanism" import type SynthesisBrain from "@/systems/simulation/synthesis_brain/SynthesisBrain.ts" import type WPILibBrain from "@/systems/simulation/wpilib_brain/WPILibBrain.ts" -export type BrainType = "synthesis" | "wpilib" | "unknown" +export type BrainType = "synthesis" | "wpilib" | "ftc" | "unknown" abstract class Brain { protected _mechanism: Mechanism @@ -24,6 +24,9 @@ abstract class Brain { public isWPILib(): this is WPILibBrain { return this.brainType == "wpilib" } + public isFTC(): this is WPILibBrain { + return this.brainType == "ftc" + } } export default Brain diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibWSWorker.ts b/fission/src/systems/simulation/shared/WSWorker.ts similarity index 88% rename from fission/src/systems/simulation/wpilib_brain/WPILibWSWorker.ts rename to fission/src/systems/simulation/shared/WSWorker.ts index a79b3aed64..802426ee6a 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibWSWorker.ts +++ b/fission/src/systems/simulation/shared/WSWorker.ts @@ -1,11 +1,14 @@ import { Mutex } from "async-mutex" +// Generic WebSocket worker shared by WPILib and FTC codesim connections. + let socket: WebSocket | undefined = undefined const connectMutex = new Mutex() let intervalHandle: NodeJS.Timeout | undefined = undefined let reconnect = false +let connectionUrl: string | undefined = undefined const RECONNECT_INTERVAL = 1000 function socketOpen(): boolean { @@ -16,13 +19,14 @@ function socketConnecting(): boolean { return (socket && socket.readyState == WebSocket.CONNECTING) ?? false } -async function tryConnect(port?: number): Promise { +async function tryConnect(): Promise { await connectMutex.runExclusive(() => { + if (!connectionUrl) return if ((socket?.readyState ?? WebSocket.CLOSED) == WebSocket.OPEN) { return } - socket = new WebSocket(`ws://localhost:${port ?? 3300}/wpilibws`) + socket = new WebSocket(connectionUrl) socket.addEventListener("open", () => { self.postMessage({ status: "open" }) @@ -47,15 +51,14 @@ async function tryDisconnect(): Promise { }) } -// Posts incoming messages function onMessage(event: MessageEvent) { self.postMessage(event.data) } -// Sends outgoing messages self.addEventListener("message", e => { switch (e.data.command) { case "enable": { + connectionUrl = e.data.url ?? connectionUrl reconnect = e.data.reconnect ?? false const intervalFunc = () => { if (intervalHandle != undefined && !socketOpen() && !socketConnecting()) { diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibBrain.ts b/fission/src/systems/simulation/wpilib_brain/WPILibBrain.ts index e8eb7f1267..3ec525f2f4 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibBrain.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibBrain.ts @@ -83,14 +83,17 @@ class WPILibBrain extends Brain { return this._assembly.assemblyId } + private _brainType: "wpilib" | "ftc" + public override get brainType() { - return "wpilib" as const + return this._brainType } - constructor(assembly: MirabufSceneObject) { + constructor(assembly: MirabufSceneObject, brainType: "wpilib" | "ftc" = "wpilib") { super(assembly.mechanism) this._assembly = assembly + this._brainType = brainType this._simLayer = World.simulationSystem.getSimulationLayer(this._mechanism)! @@ -107,7 +110,7 @@ class WPILibBrain extends Brain { this.loadSimConfig() World.sceneRenderer.mirabufSceneObjects.getRobots().forEach(v => { - if (v.brain?.isWPILib()) { + if (v.brain?.isWPILib() || v.brain?.isFTC()) { v.brain = new SynthesisBrain(v) } }) diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibState.ts b/fission/src/systems/simulation/wpilib_brain/WPILibState.ts index 8e71081a71..b5fcb351bc 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibState.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibState.ts @@ -1,6 +1,6 @@ import PreferencesSystem from "@/systems/preferences/PreferencesSystem" import type WPILibBrain from "./WPILibBrain" -import { type SimMap, worker } from "./WPILibTypes" +import { FTC_WS_URL, type SimMap, WPILIB_WS_URL, worker } from "./WPILibTypes" export const simMaps = new Map() @@ -14,6 +14,7 @@ export function setSimBrain(brain: WPILibBrain | undefined) { if (simBrain) worker.getValue().postMessage({ command: "enable", + url: simBrain.brainType === "ftc" ? FTC_WS_URL : WPILIB_WS_URL, reconnect: PreferencesSystem.getUserPreference("SimAutoReconnect"), }) } @@ -40,35 +41,3 @@ export function setConnected(connected: boolean) { export function getIsConnected() { return isConnected } - -/* -export const supplierTypeMap: { [k in SimType]: NoraTypes | undefined } = { - [SimType.PWM]: NoraTypes.NUMBER, - [SimType.SIM_DEVICE]: undefined, - [SimType.CAN_MOTOR]: NoraTypes.NUMBER, - [SimType.SOLENOID]: NoraTypes.NUMBER, - [SimType.CAN_ENCODER]: undefined, - [SimType.GYRO]: undefined, - [SimType.ACCELEROMETER]: undefined, - [SimType.DIO]: NoraTypes.NUMBER, // ? - [SimType.AI]: undefined, - [SimType.AO]: NoraTypes.NUMBER, - [SimType.DRIVERS_STATION]: undefined, - [SimType.CAMERA]: undefined, -} - -export const receiverTypeMap: { [k in SimType]: NoraTypes | undefined } = { - [SimType.PWM]: undefined, - [SimType.SIM_DEVICE]: undefined, - [SimType.CAN_MOTOR]: undefined, - [SimType.SOLENOID]: undefined, - [SimType.CAN_ENCODER]: NoraTypes.NUMBER2, - [SimType.GYRO]: NoraTypes.GYRO, - [SimType.ACCELEROMETER]: NoraTypes.ACCEL, - [SimType.DIO]: NoraTypes.NUMBER, // ? - [SimType.AI]: NoraTypes.NUMBER, - [SimType.AO]: undefined, - [SimType.DRIVERS_STATION]: undefined, - [SimType.CAMERA]: undefined, -} -*/ diff --git a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts index dc08230b7d..372ab361f1 100644 --- a/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts +++ b/fission/src/systems/simulation/wpilib_brain/WPILibTypes.ts @@ -1,5 +1,8 @@ import Lazy from "@/util/Lazy.ts" -import WPILibWSWorker from "./WPILibWSWorker?worker" +import WSWorker from "../shared/WSWorker?worker" + +export const WPILIB_WS_URL = "ws://localhost:3300/wpilibws" +export const FTC_WS_URL = "ws://localhost:3301/ftcsimws" export type DeviceName = string export type DeviceData = Map @@ -18,6 +21,7 @@ export enum SimType { AI = "AI", AO = "AO", DRIVERS_STATION = "DriverStation", + GAMEPAD = "Gamepad", CAMERA = "Camera", } @@ -63,4 +67,4 @@ export const CAMERA_HEIGHT = " = new Lazy(() => new WPILibWSWorker()) +export const worker: Lazy = new Lazy(() => new WSWorker()) diff --git a/fission/src/systems/simulation/wpilib_brain/sim/SimDriverStation.ts b/fission/src/systems/simulation/wpilib_brain/sim/SimDriverStation.ts index f03902d6ee..011e8cc167 100644 --- a/fission/src/systems/simulation/wpilib_brain/sim/SimDriverStation.ts +++ b/fission/src/systems/simulation/wpilib_brain/sim/SimDriverStation.ts @@ -20,8 +20,8 @@ export default class SimDriverStation { const enabled = mode != RobotSimMode.DISABLED const autonomous = mode == RobotSimMode.AUTO SimGeneric.set(SimType.DRIVERS_STATION, "", ">ds", true) - SimGeneric.set(SimType.DRIVERS_STATION, "", ">enabled", enabled) SimGeneric.set(SimType.DRIVERS_STATION, "", ">autonomous", autonomous) + SimGeneric.set(SimType.DRIVERS_STATION, "", ">enabled", enabled) } public static setStation(station: AllianceStation) { diff --git a/fission/src/systems/simulation/wpilib_brain/sim/SimGamepad.ts b/fission/src/systems/simulation/wpilib_brain/sim/SimGamepad.ts new file mode 100644 index 0000000000..574c2f240f --- /dev/null +++ b/fission/src/systems/simulation/wpilib_brain/sim/SimGamepad.ts @@ -0,0 +1,83 @@ +import InputSystem from "@/systems/input/InputSystem" +import type { KeyCode } from "@/systems/input/KeyboardTypes" +import { SimInput } from "../SimInput" +import { SimType, worker } from "../WPILibTypes" + +const GAMEPAD_AXIS = { LEFT_X: 0, LEFT_Y: 1, RIGHT_X: 2, RIGHT_Y: 3 } +const GAMEPAD_BUTTON = { + A: 0, + B: 1, + X: 2, + Y: 3, + LEFT_BUMPER: 4, + RIGHT_BUMPER: 5, + LEFT_TRIGGER: 6, + RIGHT_TRIGGER: 7, + BACK: 8, + START: 9, + DPAD_UP: 12, + DPAD_DOWN: 13, + DPAD_LEFT: 14, + DPAD_RIGHT: 15, +} + +export class SimGamepadInput extends SimInput { + public update(_deltaT: number) { + const data = InputSystem.gamepad ? this.readPhysicalGamepad() : this.readKeyboardGamepad() + + worker.getValue().postMessage({ + command: "update", + data: { type: SimType.GAMEPAD, device: this._device, data }, + }) + } + + private readPhysicalGamepad() { + const gamepad = InputSystem.gamepad! + return { + left_stick_x: InputSystem.getGamepadAxis(GAMEPAD_AXIS.LEFT_X), + left_stick_y: InputSystem.getGamepadAxis(GAMEPAD_AXIS.LEFT_Y), + right_stick_x: InputSystem.getGamepadAxis(GAMEPAD_AXIS.RIGHT_X), + right_stick_y: InputSystem.getGamepadAxis(GAMEPAD_AXIS.RIGHT_Y), + left_trigger: gamepad.buttons[GAMEPAD_BUTTON.LEFT_TRIGGER]?.value ?? 0, + right_trigger: gamepad.buttons[GAMEPAD_BUTTON.RIGHT_TRIGGER]?.value ?? 0, + a: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.A), + b: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.B), + x: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.X), + y: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.Y), + left_bumper: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.LEFT_BUMPER), + right_bumper: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.RIGHT_BUMPER), + dpad_up: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.DPAD_UP), + dpad_down: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.DPAD_DOWN), + dpad_left: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.DPAD_LEFT), + dpad_right: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.DPAD_RIGHT), + start: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.START), + back: InputSystem.isGamepadButtonPressed(GAMEPAD_BUTTON.BACK), + } + } + + private readKeyboardGamepad() { + const axis = (positiveKey: KeyCode, negativeKey: KeyCode) => + (InputSystem.isKeyPressed(positiveKey) ? 1 : 0) - (InputSystem.isKeyPressed(negativeKey) ? 1 : 0) + + return { + left_stick_x: 0, + left_stick_y: axis("KeyS", "KeyW"), + right_stick_x: axis("KeyD", "KeyA"), + right_stick_y: 0, + left_trigger: 0, + right_trigger: 0, + a: false, + b: false, + x: false, + y: false, + left_bumper: false, + right_bumper: false, + dpad_up: false, + dpad_down: false, + dpad_left: false, + dpad_right: false, + start: false, + back: false, + } + } +} diff --git a/fission/src/ui/components/TopBar.tsx b/fission/src/ui/components/TopBar.tsx index 344653d1b8..982d8cae66 100644 --- a/fission/src/ui/components/TopBar.tsx +++ b/fission/src/ui/components/TopBar.tsx @@ -29,7 +29,7 @@ import { TopBarFitProvider } from "@/ui/components/topbar/TopBarFitProvider" import { TopBarIcon } from "@/ui/components/topbar/TopBarIcons" import { useAssemblySelection } from "@/ui/components/topbar/UseConfigureAssembly" import UserIcon from "@/ui/components/UserIcon" -import { hasSimBrain } from "@/systems/simulation/wpilib_brain/WPILibState" +import { getIsConnected, hasSimBrain } from "@/systems/simulation/wpilib_brain/WPILibState" import { useTourAnchor } from "@/ui/tour/TourProviderHelpers" const TUTORIALS_URL = "https://synthesis.autodesk.com/tutorials" @@ -183,7 +183,7 @@ const TopBar: React.FC = () => { {hasSimBrain() && ( <> - + )} diff --git a/fission/src/ui/components/topbar/CodeConnectionIndicator.tsx b/fission/src/ui/components/topbar/CodeConnectionIndicator.tsx index 6444dcd1f4..513133b7a0 100644 --- a/fission/src/ui/components/topbar/CodeConnectionIndicator.tsx +++ b/fission/src/ui/components/topbar/CodeConnectionIndicator.tsx @@ -1,20 +1,24 @@ import { Box, Tooltip } from "@mui/material" import type React from "react" import { useEffect, useState } from "react" -import { getIsConnected } from "@/systems/simulation/wpilib_brain/WPILibState" import { SynthesisIcons } from "@/ui/components/StyledComponents" import { TOP_BAR_GLYPH_SX } from "@/ui/components/topbar/TopBarConfig" -/** small status glyph on the codesim menu reflecting wpilib code connection */ -const CodeConnectionIndicator: React.FC = () => { +interface CodeConnectionIndicatorProps { + label: string + getIsConnected: () => boolean +} + +/** small status glyph on the codesim menu reflecting code connection */ +const CodeConnectionIndicator: React.FC = ({ label, getIsConnected }) => { const [connected, setConnected] = useState(false) useEffect(() => { const handle = setInterval(() => setConnected(getIsConnected()), 500) return () => clearInterval(handle) - }, []) + }, [getIsConnected]) - const tooltip = connected ? "Code connection: connected" : "Code connection: not connected" + const tooltip = connected ? `${label}: connected` : `${label}: not connected` return ( // not a TopBarIcon since it is a stateful component diff --git a/fission/src/ui/components/topbar/CodesimControls.tsx b/fission/src/ui/components/topbar/CodesimControls.tsx index 8da2b2599d..0ed5d1edff 100644 --- a/fission/src/ui/components/topbar/CodesimControls.tsx +++ b/fission/src/ui/components/topbar/CodesimControls.tsx @@ -12,21 +12,22 @@ type CodesimButton = { label: string mode: ConfigMode icon: React.ReactNode - requiresWpilibBrain?: boolean + requiresCodesimBrain?: boolean } const CODESIM_BUTTONS: CodesimButton[] = [ { label: "Brain", mode: ConfigMode.BRAIN, icon: }, - { label: "Simulation", mode: ConfigMode.SIM, icon: , requiresWpilibBrain: true }, + { label: "Simulation", mode: ConfigMode.SIM, icon: , requiresCodesimBrain: true }, ] const CodesimControls: React.FC<{ selectedAssembly?: MirabufSceneObject }> = ({ selectedAssembly }) => { - const { isField, isWpilibBrain, openConfig, disabledMessage } = useConfigureAssembly(selectedAssembly) + const { isField, isCodesimBrain, openConfig, disabledMessage } = useConfigureAssembly(selectedAssembly) // codesim is robot only - const disabledTooltip = ({ requiresWpilibBrain }: CodesimButton) => { + const disabledTooltip = ({ requiresCodesimBrain }: CodesimButton) => { + if (!selectedAssembly) return "Spawn an assembly first" if (isField) return "Select a robot to configure" - if (requiresWpilibBrain && !isWpilibBrain) return "Set this robot's brain to WPILib first" + if (requiresCodesimBrain && !isCodesimBrain) return "Set this robot's brain to WPILib or FTC first" return undefined } diff --git a/fission/src/ui/components/topbar/UseConfigureAssembly.ts b/fission/src/ui/components/topbar/UseConfigureAssembly.ts index da217a68c1..a1badeb1da 100644 --- a/fission/src/ui/components/topbar/UseConfigureAssembly.ts +++ b/fission/src/ui/components/topbar/UseConfigureAssembly.ts @@ -79,8 +79,8 @@ export function useConfigureAssembly(selectedAssembly?: MirabufSceneObject) { const configurationType: ConfigurationType = isField ? "FIELDS" : "ROBOTS" const configureButtons = isField ? FIELD_CONFIGURE_BUTTONS : ROBOT_CONFIGURE_BUTTONS - // simulation only available when wpilib brain is enabled - const isWpilibBrain = selectedAssembly?.brain?.isWPILib() ?? false + // simulation only available when a codesim-capable brain is enabled + const isCodesimBrain = (selectedAssembly?.brain?.isWPILib() || selectedAssembly?.brain?.isFTC()) ?? false const openConfig = useCallback( (mode: ConfigMode) => { @@ -110,7 +110,7 @@ export function useConfigureAssembly(selectedAssembly?: MirabufSceneObject) { return { isField, - isWpilibBrain, + isCodesimBrain, configurationType, configureButtons, openConfig, diff --git a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts index 625eecc8df..bb8492e3c9 100644 --- a/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts +++ b/fission/src/ui/panels/configuring/assembly-config/ConfigTypes.ts @@ -125,10 +125,9 @@ export const wpilibRobotConfigModes = [ new ConfigModeSelectionOption( "Simulation", ConfigMode.SIM, - "Configure the WPILib simulation settings for this robot." + "Configure the code simulation settings for this robot." ), ] - export const fieldConfigModes = [ new ConfigModeSelectionOption("Move", ConfigMode.MOVE, "Adjust position of field relative to robot."), new ConfigModeSelectionOption( diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/BrainSelectionInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/BrainSelectionInterface.tsx index 88847beb98..cd21292f74 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/BrainSelectionInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/BrainSelectionInterface.tsx @@ -13,7 +13,9 @@ function createBrain(assembly: MirabufSceneObject, brainType: BrainType): Brain case "synthesis": return new SynthesisBrain(assembly) case "wpilib": - return new WPILibBrain(assembly) + return new WPILibBrain(assembly, "wpilib") + case "ftc": + return new WPILibBrain(assembly, "ftc") default: return } @@ -58,6 +60,7 @@ const BrainSelectionInterface: ConfigurationSubpanelComponent = ({ selectedAssem > Synthesis Brain WPILib Brain + FTC Brain ) } diff --git a/fission/src/ui/panels/configuring/assembly-config/interfaces/SimulationInterface.tsx b/fission/src/ui/panels/configuring/assembly-config/interfaces/SimulationInterface.tsx index 87e0a26b2f..997fb3b039 100644 --- a/fission/src/ui/panels/configuring/assembly-config/interfaces/SimulationInterface.tsx +++ b/fission/src/ui/panels/configuring/assembly-config/interfaces/SimulationInterface.tsx @@ -1,16 +1,22 @@ +import { Stack } from "@mui/material" import { useEffect, useState } from "react" import { setSpotlightAssembly } from "@/mirabuf/MirabufSceneObject" import PreferencesSystem from "@/systems/preferences/PreferencesSystem" +import SimDriverStation from "@/systems/simulation/wpilib_brain/sim/SimDriverStation" +import { RobotSimMode } from "@/systems/simulation/wpilib_brain/WPILibTypes" import Checkbox from "@/ui/components/Checkbox" import { Button } from "@/ui/components/StyledComponents" -import { useUIContext } from "@/ui/helpers/UIProviderHelpers" +import { CloseType, useUIContext } from "@/ui/helpers/UIProviderHelpers" +import AutoTestPanel from "@/ui/panels/simulation/AutoTestPanel" import WiringPanel from "@/ui/panels/simulation/WiringPanel" import type { ConfigurationSubpanelComponent } from "@/panels/configuring/assembly-config/ConfigTypes.ts" -import { Stack } from "@mui/material" const SimulationInterface: ConfigurationSubpanelComponent = ({ selectedAssembly, panel, registerCleanupFunction }) => { - const { openPanel } = useUIContext() + const { openPanel, closePanel } = useUIContext() const [autoReconnect, setAutoReconnect] = useState(PreferencesSystem.getUserPreference("SimAutoReconnect")) + const [teleopEnabled, setTeleopEnabled] = useState(() => SimDriverStation.isEnabled()) + const supportsAutoTesting = + (selectedAssembly?.brain?.isWPILib() ?? false) || (selectedAssembly?.brain?.isFTC() ?? false) useEffect(() => { const originalAutoReconnect = PreferencesSystem.getUserPreference("SimAutoReconnect") @@ -38,6 +44,28 @@ const SimulationInterface: ConfigurationSubpanelComponent = ({ selectedAssembly, > Wiring Panel + + {selectedAssembly?.brain?.isFTC() && ( + + )} ) } diff --git a/fission/src/ui/panels/simulation/AutoTestPanel.tsx b/fission/src/ui/panels/simulation/AutoTestPanel.tsx index 8a9403aac7..9d3974ff40 100644 --- a/fission/src/ui/panels/simulation/AutoTestPanel.tsx +++ b/fission/src/ui/panels/simulation/AutoTestPanel.tsx @@ -150,7 +150,9 @@ function captureBodies(): BodyCapture[] { function resetBodies(captures: BodyCapture[]) { const zero = new JOLT.Vec3(0, 0, 0) captures.forEach(x => { - World.physicsSystem.setBodyPositionRotationAndVelocity(x.id, x.pos, x.rot, zero, zero) + const position = new JOLT.RVec3(x.pos.GetX(), x.pos.GetY(), x.pos.GetZ()) + const rotation = new JOLT.Quat(x.rot.GetX(), x.rot.GetY(), x.rot.GetZ(), x.rot.GetW()) + World.physicsSystem.setBodyPositionRotationAndVelocity(x.id, position, rotation, zero, zero) }) JOLT.destroy(zero) } @@ -297,7 +299,10 @@ const AutoTestPanel: React.FC> = ({ panel }) => { const { configureScreen } = useUIContext() const assembly = useMemo( - () => World.sceneRenderer.mirabufSceneObjects.findWhere(x => x.brain?.isWPILib() ?? false), + () => + World.sceneRenderer.mirabufSceneObjects.findWhere( + x => (x.brain?.isWPILib() ?? false) || (x.brain?.isFTC() ?? false) + ), [] ) @@ -315,7 +320,7 @@ const AutoTestPanel: React.FC> = ({ panel }) => { useEffect(() => { World.physicsSystem.holdPause(AUTO_TEST_PAUSE_REF) if (assembly == null) { - console.warn("Couldn't find assembly with wpilib brain") + console.warn("Couldn't find assembly with a codesim brain") return } setActiveProps({ diff --git a/fission/src/ui/panels/simulation/WiringPanel.tsx b/fission/src/ui/panels/simulation/WiringPanel.tsx index 30633d48d3..2ef14d1c19 100644 --- a/fission/src/ui/panels/simulation/WiringPanel.tsx +++ b/fission/src/ui/panels/simulation/WiringPanel.tsx @@ -226,8 +226,9 @@ const SimIoComponent: React.FC = ({ setConfigState, simCon ) } -const RobotIoComponent: React.FC = ({ setConfigState, simConfig }) => { +const RobotIoComponent: React.FC = ({ setConfigState, simConfig, selectedAssembly }) => { const theme = useTheme() + const ftcActive = selectedAssembly.brain?.isFTC() ?? false const [refreshHook, refreshCheckboxes] = useReducer(x => !x, false) @@ -284,20 +285,28 @@ const RobotIoComponent: React.FC = ({ setConfigState, simC - + {canEncoders} - - {accelerometers} + {!ftcActive && ( + <> + + {accelerometers} + + )} - + {canMotors} - - {pwmDevices} + {!ftcActive && ( + <> + + {pwmDevices} + + )} diff --git a/simulation/SyntheSimFTC/.gitignore b/simulation/SyntheSimFTC/.gitignore new file mode 100644 index 0000000000..4fdf278986 --- /dev/null +++ b/simulation/SyntheSimFTC/.gitignore @@ -0,0 +1,11 @@ +# Ignore Gradle project-specific cache directory +.gradle +.vscode + +# Ignore Gradle build output directory +build/ +ctre-sil/ +bin/ + +.settings/ +.classpath diff --git a/simulation/SyntheSimFTC/build.gradle b/simulation/SyntheSimFTC/build.gradle new file mode 100644 index 0000000000..93403b17ef --- /dev/null +++ b/simulation/SyntheSimFTC/build.gradle @@ -0,0 +1,44 @@ +/* + * SyntheSimFTC: clean-room shim of the FTC SDK's hardware/opmode surface, + * plus the OpModeRunner harness that compiles+runs team OpMode source and + * bridges it to Fission over a WebSocket. + */ + +plugins { + id 'java-library' + id 'application' + id 'maven-publish' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.java-websocket:Java-WebSocket:1.5.7' + implementation 'com.google.code.gson:gson:2.11.0' +} + +application { + mainClass = 'com.autodesk.synthesis.ftc.OpModeRunner' +} + +java { + withJavadocJar() + withSourcesJar() + + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +publishing() { + publications { + maven(MavenPublication) { + group = 'com.autodesk.synthesis' + artifactId = 'SyntheSimFTC' + version = '1.0.0' + + from components.java + } + } +} diff --git a/simulation/SyntheSimFTC/gradle.properties b/simulation/SyntheSimFTC/gradle.properties new file mode 100644 index 0000000000..18f452c73f --- /dev/null +++ b/simulation/SyntheSimFTC/gradle.properties @@ -0,0 +1,6 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.jar b/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e6441136f3 Binary files /dev/null and b/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.jar differ diff --git a/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.properties b/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..a351597e62 --- /dev/null +++ b/simulation/SyntheSimFTC/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/simulation/SyntheSimFTC/gradlew b/simulation/SyntheSimFTC/gradlew new file mode 100755 index 0000000000..b740cf1339 --- /dev/null +++ b/simulation/SyntheSimFTC/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/simulation/SyntheSimFTC/gradlew.bat b/simulation/SyntheSimFTC/gradlew.bat new file mode 100644 index 0000000000..25da30dbde --- /dev/null +++ b/simulation/SyntheSimFTC/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/simulation/SyntheSimFTC/settings.gradle b/simulation/SyntheSimFTC/settings.gradle new file mode 100644 index 0000000000..984f946052 --- /dev/null +++ b/simulation/SyntheSimFTC/settings.gradle @@ -0,0 +1,7 @@ +/* + * Settings for SyntheSimFTC. + */ + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/ConsoleTelemetry.java b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/ConsoleTelemetry.java new file mode 100644 index 0000000000..ca2e926938 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/ConsoleTelemetry.java @@ -0,0 +1,43 @@ +package com.autodesk.synthesis.ftc; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.firstinspires.ftc.robotcore.external.Telemetry; + +/** + * Prints to stdout on update(). + * + * Should revisit when we add telemetry output to fission. + */ +public class ConsoleTelemetry implements Telemetry { + private final Map data = new LinkedHashMap<>(); + + @Override + public void addData(String caption, Object value) { + data.put(caption, value); + } + + @Override + public void addData(String caption, String format, Object... args) { + data.put(caption, String.format(format, args)); + } + + @Override + public void addLine(String lineCaption) { + data.put(lineCaption, ""); + } + + @Override + public boolean update() { + StringBuilder sb = new StringBuilder("[telemetry] "); + data.forEach((k, v) -> sb.append(k).append(": ").append(v).append(" ")); + System.out.println(sb); + data.clear(); + return true; + } + + @Override + public void clear() { + data.clear(); + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/FTCWsBridge.java b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/FTCWsBridge.java new file mode 100644 index 0000000000..9cc2bb3363 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/FTCWsBridge.java @@ -0,0 +1,193 @@ +package com.autodesk.synthesis.ftc; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.qualcomm.robotcore.hardware.Gamepad; +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; + +public class FTCWsBridge extends WebSocketServer { + public static final int DEFAULT_PORT = 3301; + private static final String PATH = "/ftcsimws"; + + public interface ConnectionListener { + void onFissionConnected(); + + void onFissionDisconnected(); + + void onDriverStationState(boolean enabled, boolean autonomous); + } + + private final Gson gson = new Gson(); + private final Gamepad gamepad1 = new Gamepad(); + private final Gamepad gamepad2 = new Gamepad(); + private final Map dcMotors = new ConcurrentHashMap<>(); + private volatile ConnectionListener listener; + + private volatile boolean dsEnabled; + private volatile boolean dsAutonomous; + + public FTCWsBridge(int port) { + super(new InetSocketAddress(port)); + } + + public Gamepad gamepad1() { + return gamepad1; + } + + public Gamepad gamepad2() { + return gamepad2; + } + + public void setConnectionListener(ConnectionListener listener) { + this.listener = listener; + } + + public boolean isDriverStationEnabled() { + return dsEnabled; + } + + public boolean isDriverStationAutonomous() { + return dsAutonomous; + } + + public void registerDcMotor(String deviceName, SynthesisDcMotor motor) { + dcMotors.put(deviceName, motor); + Map init = new HashMap<>(); + init.put(" data = new HashMap<>(); + data.put(" init = new HashMap<>(); + init.put(" data) { + JsonObject message = new JsonObject(); + message.addProperty("type", type); + message.addProperty("device", device); + message.add("data", gson.toJsonTree(data)); + String json = gson.toJson(message); + + for (WebSocket conn : getConnections()) { + conn.send(json); + } + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + if (!PATH.equals(handshake.getResourceDescriptor())) { + conn.close(1002, "expected path " + PATH); + return; + } + System.out.println("[FTCWsBridge] Fission connected from " + conn.getRemoteSocketAddress()); + registerDriverStation(); + ConnectionListener l = listener; + if (l != null) { + l.onFissionConnected(); + } + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + System.out.println("[FTCWsBridge] Fission disconnected: " + reason); + dcMotors.clear(); + ConnectionListener l = listener; + if (l != null) { + l.onFissionDisconnected(); + } + } + + @Override + public void onMessage(WebSocket conn, String message) { + JsonObject json; + try { + json = gson.fromJson(message, JsonObject.class); + } catch (Exception e) { + System.err.println("[FTCWsBridge] Malformed message: " + message); + return; + } + if (json == null || !json.has("type") || !json.has("data")) { + return; + } + + String type = json.get("type").getAsString(); + if ("Gamepad".equals(type)) { + String device = json.has("device") ? json.get("device").getAsString() : "1"; + applyGamepadUpdate("2".equals(device) ? gamepad2 : gamepad1, json.getAsJsonObject("data")); + } else if ("CANEncoder".equals(type) && json.has("device")) { + SynthesisDcMotor motor = dcMotors.get(json.get("device").getAsString()); + if (motor != null) { + motor.applyEncoderUpdate(json.getAsJsonObject("data")); + } + } else if ("DriverStation".equals(type)) { + applyDriverStationUpdate(json.getAsJsonObject("data")); + } + } + + private void applyDriverStationUpdate(JsonObject data) { + boolean changed = false; + if (data.has(">enabled")) { + dsEnabled = data.get(">enabled").getAsBoolean(); + changed = true; + } + if (data.has(">autonomous")) { + dsAutonomous = data.get(">autonomous").getAsBoolean(); + changed = true; + } + if (!changed) { + return; + } + + ConnectionListener l = listener; + if (l != null) { + l.onDriverStationState(dsEnabled, dsAutonomous); + } + } + + private void applyGamepadUpdate(Gamepad gamepad, JsonObject data) { + if (data.has("left_stick_x")) gamepad.left_stick_x = data.get("left_stick_x").getAsFloat(); + if (data.has("left_stick_y")) gamepad.left_stick_y = data.get("left_stick_y").getAsFloat(); + if (data.has("right_stick_x")) gamepad.right_stick_x = data.get("right_stick_x").getAsFloat(); + if (data.has("right_stick_y")) gamepad.right_stick_y = data.get("right_stick_y").getAsFloat(); + if (data.has("left_trigger")) gamepad.left_trigger = data.get("left_trigger").getAsFloat(); + if (data.has("right_trigger")) gamepad.right_trigger = data.get("right_trigger").getAsFloat(); + if (data.has("a")) gamepad.a = data.get("a").getAsBoolean(); + if (data.has("b")) gamepad.b = data.get("b").getAsBoolean(); + if (data.has("x")) gamepad.x = data.get("x").getAsBoolean(); + if (data.has("y")) gamepad.y = data.get("y").getAsBoolean(); + if (data.has("dpad_up")) gamepad.dpad_up = data.get("dpad_up").getAsBoolean(); + if (data.has("dpad_down")) gamepad.dpad_down = data.get("dpad_down").getAsBoolean(); + if (data.has("dpad_left")) gamepad.dpad_left = data.get("dpad_left").getAsBoolean(); + if (data.has("dpad_right")) gamepad.dpad_right = data.get("dpad_right").getAsBoolean(); + if (data.has("left_bumper")) gamepad.left_bumper = data.get("left_bumper").getAsBoolean(); + if (data.has("right_bumper")) gamepad.right_bumper = data.get("right_bumper").getAsBoolean(); + if (data.has("start")) gamepad.start = data.get("start").getAsBoolean(); + if (data.has("back")) gamepad.back = data.get("back").getAsBoolean(); + } + + @Override + public void onError(WebSocket conn, Exception ex) { + System.err.println("[FTCWsBridge] Error: " + ex.getMessage()); + } + + @Override + public void onStart() { + System.out.println("[FTCWsBridge] Listening on ws://localhost:" + getPort() + PATH); + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/OpModeRunner.java b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/OpModeRunner.java new file mode 100644 index 0000000000..7026cb61c6 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/OpModeRunner.java @@ -0,0 +1,287 @@ +package com.autodesk.synthesis.ftc; + +import com.qualcomm.robotcore.eventloop.opmode.Autonomous; +import com.qualcomm.robotcore.eventloop.opmode.Disabled; +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.eventloop.opmode.OpModeManagerBridge; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import com.qualcomm.robotcore.hardware.HardwareMap; +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; + +/** + * Headless stand-in for FTC's OnBotJava: compiles a plain directory of team + * source with the JDK's own compiler (no Gradle/Android project needed), + * classloads the result, finds the @TeleOp/@Autonomous LinearOpMode by + * reflection (same discovery mechanism the real SDK uses on-device), and drives + * its lifecycle off Fission WS connect/disconnect events. + */ +public class OpModeRunner { + public static void main(String[] args) throws Exception { + Path srcDir = null; + String opModeName = null; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--src" -> srcDir = Path.of(args[++i]); + case "--opmode" -> opModeName = args[++i]; + default -> throw new IllegalArgumentException("Unrecognized argument: " + args[i]); + } + } + if (srcDir == null) { + throw new IllegalArgumentException("Usage: OpModeRunner --src [--opmode ]"); + } + + OpModeSlots slots = compileAndDiscover(srcDir, opModeName); + System.out.println("[OpModeRunner] TeleOp slot: " + slots.describe(slots.teleOp())); + System.out.println("[OpModeRunner] Autonomous slot: " + slots.describe(slots.autonomous())); + + FTCWsBridge bridge = new FTCWsBridge(FTCWsBridge.DEFAULT_PORT); + OpModeLifecycle lifecycle = new OpModeLifecycle(slots, bridge); + bridge.setConnectionListener(lifecycle); + bridge.start(); + } + + /** Owns spawning/stopping a fresh OpMode instance+thread per Fission connect/disconnect cycle. */ + private static class OpModeLifecycle implements FTCWsBridge.ConnectionListener { + private final OpModeSlots slots; + private final FTCWsBridge bridge; + private OpModeCandidate running; + private LinearOpMode current; + private Thread thread; + + OpModeLifecycle(OpModeSlots slots, FTCWsBridge bridge) { + this.slots = slots; + this.bridge = bridge; + } + + @Override + public synchronized void onFissionConnected() { + System.out.println("[OpModeRunner] Waiting for the driver station to enable"); + } + + @Override + public synchronized void onFissionDisconnected() { + stopCurrent(); + } + + @Override + public synchronized void onDriverStationState(boolean enabled, boolean autonomous) { + OpModeCandidate desired = !enabled ? null : autonomous ? slots.autonomous() : slots.teleOp(); + + if (enabled && desired == null) { + System.out.println("[OpModeRunner] Driver station enabled in " + + (autonomous ? "autonomous" : "teleop") + ", but no such OpMode was discovered"); + } + + if (desired != null && running != null && desired.cls() == running.cls()) { + return; + } + + stopCurrent(); + if (desired != null) { + start(desired); + } + } + + private void start(OpModeCandidate candidate) { + try { + Constructor ctor = candidate.cls().getDeclaredConstructor(); + ctor.setAccessible(true); + LinearOpMode opMode = ctor.newInstance(); + opMode.hardwareMap = new HardwareMap(this::createDevice); + opMode.gamepad1 = bridge.gamepad1(); + opMode.gamepad2 = bridge.gamepad2(); + opMode.telemetry = new ConsoleTelemetry(); + + opMode.resetRuntime(); + current = opMode; + running = candidate; + thread = new Thread(() -> { + try { + opMode.runOpMode(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + System.err.println("[OpModeRunner] OpMode threw: " + e); + e.printStackTrace(); + } + }, "ftc-opmode"); + thread.start(); + OpModeManagerBridge.start(opMode); + System.out.println("[OpModeRunner] Started [" + candidate.kind() + "] " + candidate.displayName()); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Unable to construct " + candidate.cls().getName(), e); + } + } + + private void stopCurrent() { + if (current == null) { + return; + } + OpModeManagerBridge.stop(current); + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + System.out.println("[OpModeRunner] Stopped [" + running.kind() + "] " + running.displayName()); + current = null; + running = null; + thread = null; + } + + private com.qualcomm.robotcore.hardware.HardwareDevice createDevice(Class requestedType, String deviceName) { + // Widen this as the shim grows past DcMotorSimple (Servo, CRServo, IMU, ...). + if (requestedType.isAssignableFrom(SynthesisDcMotor.class)) { + return new SynthesisDcMotor(deviceName, bridge); + } + + return null; + } + } + + // A discovered OpMode. {@code displayName} is the annotation's name when the team set one + private record OpModeCandidate(Class cls, boolean autonomous, String displayName) { + String kind() { + return autonomous ? "Autonomous" : "TeleOp"; + } + + boolean matches(String requested) { + return cls.getSimpleName().equals(requested) + || cls.getName().equals(requested) + || displayName.equals(requested); + } + } + + //The one teleop and one autonomous the driver station can switch between without restarting the process. + private record OpModeSlots(OpModeCandidate teleOp, OpModeCandidate autonomous) { + String describe(OpModeCandidate candidate) { + return candidate == null ? "(none)" : candidate.displayName() + " (" + candidate.cls().getName() + ")"; + } + } + + private static OpModeSlots compileAndDiscover(Path srcDir, String requestedName) throws Exception { + List sourceFiles; + try (Stream walk = Files.walk(srcDir)) { + sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + + if (sourceFiles.isEmpty()) { + throw new IllegalArgumentException("No .java files found under " + srcDir); + } + + Path outDir = Files.createTempDirectory("ftc-codesim-classes"); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("No system Java compiler available -- run with a JDK, not a JRE"); + } + + List compilerArgs = new ArrayList<>(List.of( + "-d", outDir.toString(), + "-cp", System.getProperty("java.class.path"))); + sourceFiles.forEach(p -> compilerArgs.add(p.toString())); + + int result = compiler.run(null, System.out, System.err, compilerArgs.toArray(new String[0])); + if (result != 0) { + throw new IllegalStateException("Compilation of " + srcDir + " failed (see errors above)"); + } + + URLClassLoader loader = new URLClassLoader(new URL[] {outDir.toUri().toURL()}, OpModeRunner.class.getClassLoader()); + + List candidates = new ArrayList<>(); + try (Stream walk = Files.walk(outDir)) { + for (Path classFile : (Iterable) walk.filter(p -> p.toString().endsWith(".class"))::iterator) { + String relative = outDir.relativize(classFile).toString(); + String className = relative.substring(0, relative.length() - ".class".length()) + .replace(File.separatorChar, '.'); + Class cls = Class.forName(className, false, loader); + OpModeCandidate candidate = asCandidate(cls); + if (candidate != null) { + candidates.add(candidate); + } + } + } + + if (candidates.isEmpty()) { + throw new IllegalStateException("No @TeleOp or @Autonomous LinearOpMode class found under " + srcDir); + } + + candidates.sort(Comparator.comparing(OpModeCandidate::displayName).thenComparing(c -> c.cls().getName())); + candidates.forEach(c -> System.out.println( + "[OpModeRunner] Discovered [" + c.kind() + "] " + c.displayName() + " (" + c.cls().getName() + ")")); + + return fillSlots(candidates, requestedName); + } + + @SuppressWarnings("unchecked") + private static OpModeCandidate asCandidate(Class cls) { + if (!LinearOpMode.class.isAssignableFrom(cls) + || Modifier.isAbstract(cls.getModifiers()) + || cls.isAnnotationPresent(Disabled.class)) { + return null; + } + + TeleOp teleOp = cls.getAnnotation(TeleOp.class); + Autonomous autonomous = cls.getAnnotation(Autonomous.class); + if (teleOp == null && autonomous == null) { + return null; + } + if (teleOp != null && autonomous != null) { + throw new IllegalStateException(cls.getName() + " is annotated both @TeleOp and @Autonomous; pick one"); + } + + String annotated = teleOp != null ? teleOp.name() : autonomous.name(); + String displayName = annotated.isBlank() ? cls.getSimpleName() : annotated; + return new OpModeCandidate((Class) cls, autonomous != null, displayName); + } + + private static OpModeSlots fillSlots(List candidates, String requestedName) { + OpModeCandidate requested = requestedName == null ? null : resolve(candidates, requestedName); + return new OpModeSlots(slotFor(candidates, requested, false), slotFor(candidates, requested, true)); + } + + // Matches --opmode against class name, fully-qualified name, or annotation name. + private static OpModeCandidate resolve(List candidates, String requestedName) { + List matches = candidates.stream().filter(c -> c.matches(requestedName)).toList(); + if (matches.isEmpty()) { + throw new IllegalArgumentException("No OpMode named " + requestedName + " found, discovered: " + + candidates.stream().map(OpModeCandidate::displayName).collect(Collectors.joining(", "))); + } + if (matches.size() > 1) { + throw new IllegalArgumentException(requestedName + " is ambiguous, it matches: " + + matches.stream().map(c -> c.cls().getName()).collect(Collectors.joining(", "))); + } + return matches.get(0); + } + + private static OpModeCandidate slotFor(List candidates, OpModeCandidate requested, boolean autonomous) { + if (requested != null && requested.autonomous() == autonomous) { + return requested; + } + + List ofKind = candidates.stream().filter(c -> c.autonomous() == autonomous).toList(); + if (ofKind.isEmpty()) { + return null; + } + if (ofKind.size() > 1) { + System.out.println("[OpModeRunner] Multiple " + ofKind.get(0).kind() + " OpModes found, using " + + ofKind.get(0).displayName() + " -- pass --opmode to choose"); + } + return ofKind.get(0); + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/SynthesisDcMotor.java b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/SynthesisDcMotor.java new file mode 100644 index 0000000000..5a27540c4d --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/autodesk/synthesis/ftc/SynthesisDcMotor.java @@ -0,0 +1,96 @@ +package com.autodesk.synthesis.ftc; + +import com.google.gson.JsonObject; +import com.qualcomm.robotcore.hardware.DcMotor; +import com.qualcomm.robotcore.hardware.HardwareDevice; + +/** + * Backs every DcMotor/DcMotorEx/DcMotorSimple request for a given + * hardwareMap device name. + */ +public class SynthesisDcMotor implements DcMotor { + private final String deviceName; + private final FTCWsBridge bridge; + + private volatile Direction direction = Direction.FORWARD; + private volatile double power = 0.0; + private volatile int currentPosition = 0; + private volatile double velocity = 0.0; + + public SynthesisDcMotor(String deviceName, FTCWsBridge bridge) { + this.deviceName = deviceName; + this.bridge = bridge; + bridge.registerDcMotor(deviceName, this); + } + + @Override + public void setDirection(Direction direction) { + this.direction = direction; + } + + @Override + public Direction getDirection() { + return direction; + } + + @Override + public void setPower(double power) { + this.power = power; + double signedPower = direction == Direction.REVERSE ? -power : power; + bridge.sendMotorPower(deviceName, signedPower); + } + + @Override + public double getPower() { + return power; + } + + @Override + public int getCurrentPosition() { + return currentPosition; + } + + @Override + public double getVelocity() { + return velocity; + } + + void applyEncoderUpdate(JsonObject data) { + if (data.has(">position")) { + currentPosition = data.get(">position").getAsInt(); + } + if (data.has(">velocity")) { + velocity = data.get(">velocity").getAsDouble(); + } + } + + @Override + public HardwareDevice.Manufacturer getManufacturer() { + return HardwareDevice.Manufacturer.Synthesis; + } + + @Override + public String getDeviceName() { + return "Synthesis DcMotor"; + } + + @Override + public String getConnectionInfo() { + return "Synthesis simulation: " + deviceName; + } + + @Override + public int getVersion() { + return 1; + } + + @Override + public void resetDeviceConfigurationForOpMode() { + direction = Direction.FORWARD; + power = 0.0; + } + + @Override + public void close() { + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Autonomous.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Autonomous.java new file mode 100644 index 0000000000..93ef138635 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Autonomous.java @@ -0,0 +1,23 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Clean-room shim of the real FTC SDK annotation. + * + *

{@link #preselectTeleOp()} is part of the real signature so team source + * compiles unchanged; the harness itself ignores it, having no driver station + * to preselect anything on. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Autonomous { + String name() default ""; + + String group() default ""; + + String preselectTeleOp() default ""; +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Disabled.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Disabled.java new file mode 100644 index 0000000000..7e5fdfb487 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/Disabled.java @@ -0,0 +1,15 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Clean-room shim of the real FTC SDK annotation. Marks a @TeleOp/@Autonomous + * class as excluded from OpMode discovery. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Disabled { +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/LinearOpMode.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/LinearOpMode.java new file mode 100644 index 0000000000..c12ef11fd9 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/LinearOpMode.java @@ -0,0 +1,50 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +/** + * Clean-room shim of the real FTC SDK class. + */ +public abstract class LinearOpMode extends OpMode { + /** + * Real hardware loops land around 50-100Hz because actual I2C/USB motor + * controller I/O has latency, ours doesn't. Without this, an opmode with + * no idle()/sleep of its own operates it loop unrealistically fast. + */ + private static final long LOOP_PERIOD_MILLIS = 10; + private static final double LOOP_PERIOD_SECONDS = LOOP_PERIOD_MILLIS / 1000.0; + + volatile boolean isStarted; + volatile boolean stopRequested; + + public abstract void runOpMode() throws InterruptedException; + + public void waitForStart() { + while (!isStarted && !stopRequested) { + idle(); + } + } + + public final boolean opModeIsActive() { + boolean active = isStarted && !stopRequested; + if (active) { + advanceRuntime(LOOP_PERIOD_SECONDS); + idle(); + } + return active; + } + + public final boolean isStopRequested() { + return stopRequested; + } + + public final void idle() { + try { + Thread.sleep(LOOP_PERIOD_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public final void requestOpModeStop() { + stopRequested = true; + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpMode.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpMode.java new file mode 100644 index 0000000000..d5970f82e7 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpMode.java @@ -0,0 +1,24 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +import com.qualcomm.robotcore.hardware.Gamepad; +import com.qualcomm.robotcore.hardware.HardwareMap; +import org.firstinspires.ftc.robotcore.external.Telemetry; + +/** + * Clean-room shim of the real FTC SDK class. + */ +public abstract class OpMode { + public HardwareMap hardwareMap; + public Gamepad gamepad1; + public Gamepad gamepad2; + public Telemetry telemetry; + public double time; + + public void resetRuntime() { + time = 0; + } + + public void advanceRuntime(double seconds) { + time += seconds; + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModeManagerBridge.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModeManagerBridge.java new file mode 100644 index 0000000000..dd304f538b --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/OpModeManagerBridge.java @@ -0,0 +1,17 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +/** + * Our harness's replacement for the real SDK's internal OpModeManagerImpl. + */ +public final class OpModeManagerBridge { + private OpModeManagerBridge() { + } + + public static void start(LinearOpMode opMode) { + opMode.isStarted = true; + } + + public static void stop(LinearOpMode opMode) { + opMode.stopRequested = true; + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/TeleOp.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/TeleOp.java new file mode 100644 index 0000000000..66cec39734 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/eventloop/opmode/TeleOp.java @@ -0,0 +1,15 @@ +package com.qualcomm.robotcore.eventloop.opmode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Clean-room shim of the real FTC SDK annotation. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface TeleOp { + String name() default ""; + + String group() default ""; +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotor.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotor.java new file mode 100644 index 0000000000..0b106abaf0 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotor.java @@ -0,0 +1,7 @@ +package com.qualcomm.robotcore.hardware; + +public interface DcMotor extends DcMotorSimple { + int getCurrentPosition(); + + double getVelocity(); +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotorSimple.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotorSimple.java new file mode 100644 index 0000000000..69d0ee903a --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/DcMotorSimple.java @@ -0,0 +1,19 @@ +package com.qualcomm.robotcore.hardware; + +/** + * Clean-room shim of the real FTC SDK interface. + */ +public interface DcMotorSimple extends HardwareDevice { + enum Direction { + FORWARD, + REVERSE, + } + + void setDirection(Direction direction); + + Direction getDirection(); + + void setPower(double power); + + double getPower(); +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/Gamepad.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/Gamepad.java new file mode 100644 index 0000000000..39571cbc81 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/Gamepad.java @@ -0,0 +1,33 @@ +package com.qualcomm.robotcore.hardware; + +/** + * Clean-room shim of the real FTC SDK class. + */ +public class Gamepad { + public volatile float left_stick_x; + public volatile float left_stick_y; + public volatile float right_stick_x; + public volatile float right_stick_y; + + public volatile boolean dpad_up; + public volatile boolean dpad_down; + public volatile boolean dpad_left; + public volatile boolean dpad_right; + + public volatile boolean a; + public volatile boolean b; + public volatile boolean x; + public volatile boolean y; + + public volatile boolean start; + public volatile boolean back; + public volatile boolean guide; + + public volatile boolean left_bumper; + public volatile boolean right_bumper; + public volatile boolean left_stick_button; + public volatile boolean right_stick_button; + + public volatile float left_trigger; + public volatile float right_trigger; +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareDevice.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareDevice.java new file mode 100644 index 0000000000..a0aa2eb364 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareDevice.java @@ -0,0 +1,23 @@ +package com.qualcomm.robotcore.hardware; + +/** + * Clean-room shim of the real FTC SDK interface. + */ +public interface HardwareDevice { + enum Manufacturer { + Unknown, + Synthesis, + } + + Manufacturer getManufacturer(); + + String getDeviceName(); + + String getConnectionInfo(); + + int getVersion(); + + void resetDeviceConfigurationForOpMode(); + + void close(); +} diff --git a/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareMap.java b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareMap.java new file mode 100644 index 0000000000..470a07aaf5 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/com/qualcomm/robotcore/hardware/HardwareMap.java @@ -0,0 +1,42 @@ +package com.qualcomm.robotcore.hardware; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Clean-room shim of the real FTC SDK class. The real HardwareMap is populated + * ahead of time from an on-device robot config XML that never exists in a + * team's source tree (it lives only on the Control Hub). We have no such + * config to read, so devices are created lazily on first {@link #get} via an + * injected {@link DeviceFactory}, then cached by name so a later {@code get()} + * with a narrower or wider requested type (e.g. DcMotor vs DcMotorEx vs + * DcMotorSimple) returns the same backing instance. + */ +public class HardwareMap { + @FunctionalInterface + public interface DeviceFactory { + HardwareDevice create(Class classOrInterface, String deviceName); + } + + private final Map devices = new ConcurrentHashMap<>(); + private final DeviceFactory deviceFactory; + + public HardwareMap(DeviceFactory deviceFactory) { + this.deviceFactory = deviceFactory; + } + + public T get(Class classOrInterface, String deviceName) { + HardwareDevice device = devices.computeIfAbsent(deviceName, name -> deviceFactory.create(classOrInterface, name)); + + if (device == null) { + throw new IllegalArgumentException( + "Unable to find a hardware device with name \"" + deviceName + "\" of type " + classOrInterface.getSimpleName()); + } + if (!classOrInterface.isInstance(device)) { + throw new IllegalArgumentException("Hardware device \"" + deviceName + "\" of type " + device.getClass().getSimpleName() + + " is not compatible with requested type " + classOrInterface.getSimpleName()); + } + + return classOrInterface.cast(device); + } +} diff --git a/simulation/SyntheSimFTC/src/main/java/org/firstinspires/ftc/robotcore/external/Telemetry.java b/simulation/SyntheSimFTC/src/main/java/org/firstinspires/ftc/robotcore/external/Telemetry.java new file mode 100644 index 0000000000..afc8c3f4b5 --- /dev/null +++ b/simulation/SyntheSimFTC/src/main/java/org/firstinspires/ftc/robotcore/external/Telemetry.java @@ -0,0 +1,17 @@ +package org.firstinspires.ftc.robotcore.external; + +/** + * Clean-room shim of the real FTC SDK interface, trimmed to the methods + * teleop OpModes actually call. + */ +public interface Telemetry { + void addData(String caption, Object value); + + void addData(String caption, String format, Object... args); + + void addLine(String lineCaption); + + boolean update(); + + void clear(); +} diff --git a/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerArcadeDrive.java b/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerArcadeDrive.java new file mode 100644 index 0000000000..a49d0d3cd6 --- /dev/null +++ b/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerArcadeDrive.java @@ -0,0 +1,43 @@ +package org.firstinspires.ftc.teamcode.examples; + +import com.qualcomm.robotcore.hardware.DcMotorSimple; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; + +/** + * Arcade drive for Dozer, a 6-wheel robot with 3 motors ganged per side. + * Left stick y drives forward/back, right stick x turns. + */ +@TeleOp(name = "Dozer Arcade Drive") +public class ExampleDozerArcadeDrive extends LinearOpMode { + @Override + public void runOpMode() { + DcMotorSimple leftFront = hardwareMap.get(DcMotorSimple.class, "leftFront"); + DcMotorSimple leftMiddle = hardwareMap.get(DcMotorSimple.class, "leftMiddle"); + DcMotorSimple leftBack = hardwareMap.get(DcMotorSimple.class, "leftBack"); + DcMotorSimple rightFront = hardwareMap.get(DcMotorSimple.class, "rightFront"); + DcMotorSimple rightMiddle = hardwareMap.get(DcMotorSimple.class, "rightMiddle"); + DcMotorSimple rightBack = hardwareMap.get(DcMotorSimple.class, "rightBack"); + + waitForStart(); + + while (opModeIsActive()) { + double drive = -gamepad1.left_stick_y; + double turn = gamepad1.right_stick_x; + + double leftPower = drive + turn; + double rightPower = drive - turn; + + double max = Math.max(1.0, Math.max(Math.abs(leftPower), Math.abs(rightPower))); + leftPower /= max; + rightPower /= max; + + leftFront.setPower(leftPower); + leftMiddle.setPower(leftPower); + leftBack.setPower(leftPower); + rightFront.setPower(rightPower); + rightMiddle.setPower(rightPower); + rightBack.setPower(rightPower); + } + } +} diff --git a/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerAutoDrive.java b/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerAutoDrive.java new file mode 100644 index 0000000000..2f08194aac --- /dev/null +++ b/simulation/samples/FTCDozerArcadeDriveSample/org/firstinspires/ftc/teamcode/examples/ExampleDozerAutoDrive.java @@ -0,0 +1,59 @@ +package org.firstinspires.ftc.teamcode.examples; + +import com.qualcomm.robotcore.eventloop.opmode.Autonomous; +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.hardware.DcMotorSimple; + +@Autonomous(name = "Dozer Autonomous Drive") +public class ExampleDozerAutoDrive extends LinearOpMode { + + private enum DriveState { + DRIVE_FORWARD, + DRIVE_BACKWARD, + STOPPED + } + + @Override + public void runOpMode() { + // Hardware initialization + DcMotorSimple leftFront = hardwareMap.get(DcMotorSimple.class, "leftFront"); + DcMotorSimple leftMiddle = hardwareMap.get(DcMotorSimple.class, "leftMiddle"); + DcMotorSimple leftBack = hardwareMap.get(DcMotorSimple.class, "leftBack"); + DcMotorSimple rightFront = hardwareMap.get(DcMotorSimple.class, "rightFront"); + DcMotorSimple rightMiddle = hardwareMap.get(DcMotorSimple.class, "rightMiddle"); + DcMotorSimple rightBack = hardwareMap.get(DcMotorSimple.class, "rightBack"); + + DriveState currentState = DriveState.DRIVE_FORWARD; + + waitForStart(); + + while (opModeIsActive()) { + if (time > 2.0) { + currentState = DriveState.STOPPED; + } else if (time > 1.0) { + currentState = DriveState.DRIVE_BACKWARD; + } else { + currentState = DriveState.DRIVE_FORWARD; + } + + double power = switch (currentState) { + case DRIVE_FORWARD -> 0.5; + case DRIVE_BACKWARD -> -0.5; + case STOPPED -> 0.0; + }; + + setAllPower(leftFront, leftMiddle, leftBack, rightFront, rightMiddle, rightBack, power); + } + } + + // Helper method to reduce code repetition when setting motor power + private void setAllPower(DcMotorSimple lf, DcMotorSimple lm, DcMotorSimple lb, + DcMotorSimple rf, DcMotorSimple rm, DcMotorSimple rb, double power) { + lf.setPower(power); + lm.setPower(power); + lb.setPower(power); + rf.setPower(power); + rm.setPower(power); + rb.setPower(power); + } +}