Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Code Simulation Support: WebSocket Client #1031

Merged
merged 19 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions fission/src/Synthesis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ import Skybox from "./ui/components/Skybox.tsx"
import ConfigureRobotModal from "./ui/modals/configuring/ConfigureRobotModal.tsx"
import ResetAllInputsModal from "./ui/modals/configuring/ResetAllInputsModal.tsx"

import WPILibWSWorker from "@/systems/simulation/wpilib_brain/WPILibWSWorker.ts?worker"
import WSViewPanel from "./ui/panels/WSViewPanel.tsx"
import Lazy from "./util/Lazy.ts"

const DEFAULT_MIRA_PATH = "/api/mira/Robots/Team 2471 (2018)_v7.mira"

const worker = new Lazy<Worker>(() => new WPILibWSWorker())

function Synthesis() {
const urlParams = new URLSearchParams(document.location.search)
const has_code = urlParams.has("code")
Expand Down Expand Up @@ -91,6 +97,8 @@ function Synthesis() {

World.InitWorld()

worker.getValue()

let mira_path = DEFAULT_MIRA_PATH

if (urlParams.has("mira")) {
Expand Down Expand Up @@ -240,6 +248,7 @@ const initialPanels: ReactElement[] = [
<ZoneConfigPanel key="zone-config" panelId="zone-config" />,
<ImportMirabufPanel key="import-mirabuf" panelId="import-mirabuf" />,
<PokerPanel key="poker" panelId="poker" />,
<WSViewPanel key="ws-view" panelId="ws-view" />,
]

export default Synthesis
233 changes: 233 additions & 0 deletions fission/src/systems/simulation/wpilib_brain/WPILibBrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Mechanism from "@/systems/physics/Mechanism"
import Brain from "../Brain"

import WPILibWSWorker from "./WPILibWSWorker?worker"

const worker = new WPILibWSWorker()

const PWM_SPEED = "<speed"
const PWM_POSITION = "<position"
const CANMOTOR_DUTY_CYCLE = "<dutyCycle"
const CANMOTOR_SUPPLY_VOLTAGE = ">supplyVoltage"
const CANENCODER_RAW_INPUT_POSITION = ">rawPositionInput"

export type SimType = "PWM" | "CANMotor" | "Solenoid" | "SimDevice" | "CANEncoder"

enum FieldType {
Read = 0,
Write = 1,
Both = 2,
Unknown = -1,
}

function GetFieldType(field: string): FieldType {
if (field.length < 2) {
return FieldType.Unknown
}

switch (field.charAt(0)) {
case "<":
return field.charAt(1) == ">" ? FieldType.Both : FieldType.Read
case ">":
return FieldType.Write
default:
return FieldType.Unknown
}
}

export const simMap = new Map<SimType, Map<string, any>>()

export class SimGeneric {
private constructor() {}
Comment on lines +39 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should simMap be a static field on SimGeneric, since the latter encapsulates former?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PepperLola How ever you two end up structuring everything will determine this.


public static Get<T>(simType: SimType, device: string, field: string, defaultValue?: T): T | undefined {
const fieldType = GetFieldType(field)
if (fieldType != FieldType.Read && fieldType != FieldType.Both) {
console.warn(`Field '${field}' is not a read or both field type`)
return undefined
}

const map = simMap.get(simType)
if (!map) {
console.warn(`No '${simType}' devices found`)
return undefined
}

const data = map.get(device)
if (!data) {
console.warn(`No '${simType}' device '${device}' found`)
return undefined
}

return (data[field] as T | undefined) ?? defaultValue
}

public static Set<T>(simType: SimType, device: string, field: string, value: T): boolean {
const fieldType = GetFieldType(field)
if (fieldType != FieldType.Write && fieldType != FieldType.Both) {
console.warn(`Field '${field}' is not a write or both field type`)
return false
}

const map = simMap.get(simType)
if (!map) {
console.warn(`No '${simType}' devices found`)
return false
}

const data = map.get(device)
if (!data) {
console.warn(`No '${simType}' device '${device}' found`)
return false
}

const selectedData: any = {}
selectedData[field] = value

data[field] = value
worker.postMessage({
command: "update",
data: {
type: simType,
device: device,
data: selectedData,
},
})

window.dispatchEvent(new SimMapUpdateEvent(true))
return true
}
}

export class SimPWM {
private constructor() {}

public static GetSpeed(device: string): number | undefined {
return SimGeneric.Get("PWM", device, PWM_SPEED, 0.0)
}

public static GetPosition(device: string): number | undefined {
return SimGeneric.Get("PWM", device, PWM_POSITION, 0.0)
}
}

export class SimCANMotor {
private constructor() {}

public static GetDutyCycle(device: string): number | undefined {
return SimGeneric.Get("CANMotor", device, CANMOTOR_DUTY_CYCLE, 0.0)
}

public static SetSupplyVoltage(device: string, voltage: number): boolean {
return SimGeneric.Set("CANMotor", device, CANMOTOR_SUPPLY_VOLTAGE, voltage)
}
}

export class SimCANEncoder {
private constructor() {}

public static SetRawInputPosition(device: string, rawInputPosition: number): boolean {
return SimGeneric.Set("CANEncoder", device, CANENCODER_RAW_INPUT_POSITION, rawInputPosition)
}
}

worker.addEventListener("message", (eventData: MessageEvent) => {
let data: any | undefined
try {
if (typeof eventData.data == "object") {
data = eventData.data
} else {
data = JSON.parse(eventData.data)
}
} catch (e) {
console.warn(`Failed to parse data:\n${JSON.stringify(eventData.data)}`)
}

if (!data || !data.type) {
console.log("No data, bailing out")
return
}

// console.debug(data)

const device = data.device
const updateData = data.data

switch (data.type) {
case "PWM":
console.debug("pwm")
UpdateSimMap("PWM", device, updateData)
break
case "Solenoid":
console.debug("solenoid")
UpdateSimMap("Solenoid", device, updateData)
break
case "SimDevice":
console.debug("simdevice")
UpdateSimMap("SimDevice", device, updateData)
break
case "CANMotor":
console.debug("canmotor")
UpdateSimMap("CANMotor", device, updateData)
break
case "CANEncoder":
console.debug("canencoder")
UpdateSimMap("CANEncoder", device, updateData)
break
default:
// console.debug(`Unrecognized Message:\n${data}`)
break
}
})

function UpdateSimMap(type: SimType, device: string, updateData: any) {
let typeMap = simMap.get(type)
if (!typeMap) {
typeMap = new Map<string, any>()
simMap.set(type, typeMap)
}

let currentData = typeMap.get(device)
if (!currentData) {
currentData = {}
typeMap.set(device, currentData)
}
Object.entries(updateData).forEach(kvp => (currentData[kvp[0]] = kvp[1]))

window.dispatchEvent(new SimMapUpdateEvent(false))
}

class WPILibBrain extends Brain {
constructor(mech: Mechanism) {
super(mech)
}

public Update(_: number): void {}

public Enable(): void {
worker.postMessage({ command: "connect" })
}

public Disable(): void {
worker.postMessage({ command: "disconnect" })
}
}

export class SimMapUpdateEvent extends Event {
public static readonly TYPE: string = "ws/sim-map-update"

private _internalUpdate: boolean

public get internalUpdate(): boolean {
return this._internalUpdate
}

public constructor(internalUpdate: boolean) {
super(SimMapUpdateEvent.TYPE)

this._internalUpdate = internalUpdate
}
}

export default WPILibBrain
65 changes: 65 additions & 0 deletions fission/src/systems/simulation/wpilib_brain/WPILibWSWorker.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: If you click WS test twice you run into a warning (the connection is not being closed I believe)
Also localhost connection is hardcoded (mine is 3000 not 3300) so it can't be in production

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Mutex } from "async-mutex"

let socket: WebSocket | undefined = undefined

const connectMutex = new Mutex()

async function tryConnect(port: number | undefined): Promise<void> {
await connectMutex
.runExclusive(() => {
if ((socket?.readyState ?? WebSocket.CLOSED) == WebSocket.OPEN) {
return
}

socket = new WebSocket(`ws://localhost:${port ?? 3300}/wpilibws`)

socket.addEventListener("open", () => {
console.log("WS Opened")
self.postMessage({ status: "open" })
})
socket.addEventListener("error", () => {
console.log("WS Could not open")
self.postMessage({ status: "error" })
})

socket.addEventListener("message", onMessage)
})
.then(() => console.debug("Mutex released"))
}

async function tryDisconnect(): Promise<void> {
await connectMutex.runExclusive(() => {
if (!socket) {
return
}

socket?.close()
socket = undefined
})
}

function onMessage(event: MessageEvent) {
// console.log(`${JSON.stringify(JSON.parse(event.data), null, '\t')}`)
self.postMessage(event.data)
}

self.addEventListener("message", e => {
switch (e.data.command) {
case "connect":
tryConnect(e.data.port)
break
case "disconnect":
tryDisconnect()
break
case "update":
if (socket) {
socket.send(JSON.stringify(e.data.data))
}
break
default:
console.warn(`Unrecognized command '${e.data.command}'`)
break
}
})

console.log("Worker started")
20 changes: 19 additions & 1 deletion fission/src/ui/components/MainHUD.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"
import { BsCodeSquare } from "react-icons/bs"
import { FaCar, FaGear, FaMagnifyingGlass, FaPlus } from "react-icons/fa6"
import { BiMenuAltLeft } from "react-icons/bi"
import { GrFormClose } from "react-icons/gr"
import { GrConnect, GrFormClose } from "react-icons/gr"
import { GiSteeringWheel } from "react-icons/gi"
import { HiDownload } from "react-icons/hi"
import { IoBug, IoGameControllerOutline, IoPeople, IoRefresh, IoTimer } from "react-icons/io5"
Expand All @@ -12,6 +12,7 @@ import { motion } from "framer-motion"
import logo from "@/assets/autodesk_logo.png"
import { ToastType, useToastContext } from "@/ui/ToastContext"
import { Random } from "@/util/Random"
import WPILibBrain from "@/systems/simulation/wpilib_brain/WPILibBrain"
import APS, { APS_USER_INFO_UPDATE_EVENT } from "@/aps/APS"
import { UserIcon } from "./UserIcon"
import World from "@/systems/World"
Expand Down Expand Up @@ -145,6 +146,7 @@ const MainHUD: React.FC = () => {
}
}}
/>
<MainHUDButton value={"WS Viewer"} icon={<GrConnect />} onClick={() => openPanel("ws-view")} />
</div>
<div className="flex flex-col gap-0 bg-background w-full rounded-3xl">
<MainHUDButton
Expand Down Expand Up @@ -173,6 +175,22 @@ const MainHUD: React.FC = () => {
onClick={() => MirabufCachingService.RemoveAll()}
/>
<MainHUDButton value={"Drivetrain"} icon={<FaCar />} onClick={() => openModal("drivetrain")} />
<MainHUDButton
value={"WS Test"}
icon={<FaCar />}
onClick={() => {
// worker?.postMessage({ command: 'connect' });
const miraObjs = [...World.SceneRenderer.sceneObjects.entries()].filter(
x => x[1] instanceof MirabufSceneObject
)
console.log(`Number of mirabuf scene objects: ${miraObjs.length}`)
if (miraObjs.length > 0) {
const mechanism = (miraObjs[0][1] as MirabufSceneObject).mechanism
const simLayer = World.SimulationSystem.GetSimulationLayer(mechanism)
simLayer?.SetBrain(new WPILibBrain(mechanism))
}
}}
/>
<MainHUDButton
value={"Toasts"}
icon={<FaCar />}
Expand Down
Loading
Loading