diff --git a/src/flags.ts b/src/flags.ts index 0046a7fbf..53baf8cbb 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -39,7 +39,11 @@ export type Flag = * Added to support user-testing and has the nice side-effect of disabling * the dialog for local development so is worth keeping for that use alone. */ - | "noWelcome"; + | "noWelcome" + /** + * Simulator. Implementation is very incomplete. + */ + | "simulator"; interface FlagMetadata { defaultOnStages: string[]; @@ -52,6 +56,7 @@ const allFlags: FlagMetadata[] = [ { name: "dndDebug", defaultOnStages: [] }, { name: "livePreview", defaultOnStages: ["local", "REVIEW"] }, { name: "noWelcome", defaultOnStages: ["local", "REVIEW"] }, + { name: "simulator", defaultOnStages: [] }, ]; type Flags = Record; diff --git a/src/simulator/RangeSensor.tsx b/src/simulator/RangeSensor.tsx new file mode 100644 index 000000000..82cde2b9b --- /dev/null +++ b/src/simulator/RangeSensor.tsx @@ -0,0 +1,70 @@ +import { Icon } from "@chakra-ui/icons"; +import { + HStack, + Slider, + SliderFilledTrack, + SliderMark, + SliderThumb, + SliderTrack, +} from "@chakra-ui/react"; +import { useCallback } from "react"; +import { RiQuestionFill } from "react-icons/ri"; +import { Sensor, sensorIcons } from "./model"; + +interface RangeSensorProps { + value: Sensor; + onSensorChange: (id: string, value: number) => void; +} + +const RangeSensor = ({ + value: { id, min, max, value, unit }, + onSensorChange, +}: RangeSensorProps) => { + const handleChange = useCallback( + (value: number) => { + onSensorChange(id, value); + }, + [onSensorChange, id] + ); + const valueText = unit ? `${value} ${unit}` : value.toString(); + return ( + + + + + + + + + {min} + + + {max} + + + {valueText} + + + + ); +}; + +export default RangeSensor; diff --git a/src/simulator/Sensors.tsx b/src/simulator/Sensors.tsx new file mode 100644 index 000000000..a7e521406 --- /dev/null +++ b/src/simulator/Sensors.tsx @@ -0,0 +1,31 @@ +import { BoxProps, Stack } from "@chakra-ui/react"; +import { Sensor } from "./model"; +import RangeSensor from "./RangeSensor"; + +interface SensorsProps extends BoxProps { + value: Sensor[]; + onSensorChange: (id: string, value: number) => void; +} + +const Sensors = ({ value, onSensorChange, ...props }: SensorsProps) => { + return ( + + {value.map((sensor) => { + switch (sensor.type) { + case "range": + return ( + + ); + default: + return null; + } + })} + + ); +}; + +export default Sensors; diff --git a/src/simulator/Simulator.tsx b/src/simulator/Simulator.tsx new file mode 100644 index 000000000..c7ce517db --- /dev/null +++ b/src/simulator/Simulator.tsx @@ -0,0 +1,48 @@ +import { AspectRatio, Box, HStack, IconButton, VStack } from "@chakra-ui/react"; +import { useRef } from "react"; +import { RiPlayFill, RiStopFill } from "react-icons/ri"; +import Sensors from "./Sensors"; +import { useSimulator } from "./simulator-hooks"; + +const Simulator = () => { + const ref = useRef(null); + const { play, stop, sensors, onSensorChange } = useSimulator(ref); + return ( + + + + + + + } + aria-label="Run" + /> + } + aria-label="Stop" + /> + + + + + ); +}; + +export default Simulator; diff --git a/src/simulator/model.ts b/src/simulator/model.ts new file mode 100644 index 000000000..37a20a211 --- /dev/null +++ b/src/simulator/model.ts @@ -0,0 +1,16 @@ +import { IconType } from "react-icons"; +import { RiSunFill, RiTempHotFill } from "react-icons/ri"; + +export const sensorIcons: Record = { + temperature: RiTempHotFill, + lightLevel: RiSunFill, +}; + +export interface Sensor { + type: "range"; + id: string; + min: number; + max: number; + value: number; + unit?: string; +} diff --git a/src/simulator/simulator-hooks.tsx b/src/simulator/simulator-hooks.tsx new file mode 100644 index 000000000..047986a19 --- /dev/null +++ b/src/simulator/simulator-hooks.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFileSystem } from "../fs/fs-hooks"; +import { Sensor } from "./model"; + +export const useSimulator = (ref: React.RefObject) => { + const fs = useFileSystem(); + const [sensors, setSensors] = useState>({}); + const readyCallbacks = useRef([] as Array<() => void>); + useEffect(() => { + if (ref.current) { + const messageListener = (e: MessageEvent) => { + const simulator = ref.current!.contentWindow; + if (e.source === simulator) { + switch (e.data.kind) { + case "ready": { + setSensors( + Object.fromEntries( + e.data.sensors.map((json: Sensor) => { + return [json.id, json]; + }) + ) + ); + while (readyCallbacks.current.length) { + readyCallbacks.current.pop()!(); + } + break; + } + case "serial_output": { + // TODO: serial + break; + } + } + } + }; + window.addEventListener("message", messageListener); + return () => { + window.removeEventListener("message", messageListener); + }; + } + }, [ref, readyCallbacks]); + + const onSensorChange = useCallback( + (id: string, value: number) => { + setSensors((sensors) => ({ + ...sensors, + [id]: { ...sensors[id], value }, + })); + const simulator = ref.current!.contentWindow!; + simulator.postMessage( + { + kind: "sensor_set", + sensor: id, + value, + }, + "*" + ); + }, + [ref, setSensors] + ); + + const play = useCallback(async () => { + const filesystem: Record = Object.fromEntries( + await Promise.all( + fs.project.files.map(async (f) => [ + f.name, + (await fs.read(f.name)).data, + ]) + ) + ); + const simulator = ref.current!.contentWindow!; + simulator.postMessage( + { + kind: "flash", + filesystem, + }, + "*" + ); + }, [ref, fs]); + + const stop = useCallback(async () => { + const simulator = ref.current!.contentWindow!; + simulator.postMessage( + { + kind: "serial_input", + // Ctrl-C to interrupt. + // A specific message would be useful as probably best to clear display etc. here. + data: `\x03`, + }, + "*" + ); + }, [ref]); + + return { + play, + stop, + sensors: Object.values(sensors), + onSensorChange, + }; +}; diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 3d49a2d36..287df03ad 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -16,10 +16,12 @@ import { SizedMode } from "../common/SplitView/SplitView"; import { ConnectionStatus } from "../device/device"; import { useConnectionStatus } from "../device/device-hooks"; import EditorArea from "../editor/EditorArea"; +import { flags } from "../flags"; import { MAIN_FILE } from "../fs/fs"; import { useProject } from "../project/project-hooks"; import ProjectActionBar from "../project/ProjectActionBar"; import SerialArea from "../serial/SerialArea"; +import Simulator from "../simulator/Simulator"; import Overlay from "./connect-dialogs/Overlay"; import SideBar from "./SideBar"; import { useSelection } from "./use-selection"; @@ -57,6 +59,18 @@ const Workbench = () => { useState("compact"); const serialSizedMode = connected ? serialStateWhenOpen : "collapsed"; const [sidebarShown, setSidebarShown] = useState(true); + const editor = ( + + {selection && fileVersion !== undefined && ( + + )} + + ); + return ( <> @@ -99,15 +113,21 @@ const Workbench = () => { mode={serialSizedMode} > - - {selection && fileVersion !== undefined && ( - - )} - + {flags.simulator ? ( + + {editor} + + + + + + ) : ( + editor + )}