├── .gitignore ├── .prettierrc.json ├── .yarn └── releases │ └── yarn-3.3.0.cjs ├── .yarnrc.yml ├── README.md ├── index.html ├── misc └── screenshot.png ├── package.json ├── src ├── App.css ├── App.tsx ├── components │ ├── Header.tsx │ ├── MotorMonitorGraph.tsx │ ├── Motors.tsx │ ├── Parameters │ │ ├── FocBoolean.tsx │ │ ├── FocScalar.tsx │ │ └── MotorControlTypeSwitch.tsx │ ├── ParametersAccordion.tsx │ ├── SerialCommandPrompt.tsx │ ├── SerialManager.tsx │ └── SerialOutputViewer.tsx ├── favicon.svg ├── index.css ├── lib │ ├── LineBreakTransformer.ts │ ├── serialContext.tsx │ ├── useAvailablePorts.ts │ ├── useInterval.tsx │ ├── useLastValue.tsx │ ├── useParameterSettings.tsx │ ├── useSerialIntervalSender.tsx │ ├── useSerialLineEvent.ts │ └── utils.ts ├── logo.svg ├── main.tsx ├── simpleFoc │ └── serial.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .pnp.* 16 | .yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | .idea 27 | .DS_Store 28 | *.suo 29 | *.ntvs* 30 | *.njsproj 31 | *.sln 32 | *.sw? 33 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.3.0.cjs 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple FOC Web Controller 2 | 3 | This is a controller interface for the [Simple FOC library](https://github.com/simplefoc/Arduino-FOC). It uses WebSerial to communicate with a suitable micro-controller on which Simple FOC is used. 4 | 5 | - Control multiple motors 6 | - Motors auto-detection 7 | - Motor monitoring with realtime graph 8 | - No install needed 9 | 10 | ![Interface screenshot](misc/screenshot.png) 11 | 12 | ## How to use 13 | 14 | - Configure and flash Simple FOC on the board of your choice following the [official documentation](https://docs.simplefoc.com/installation) 15 | - Configure each of your motor to [use monitoring](https://docs.simplefoc.com/monitoring) 16 | - If you which to control your motors through the web controller (and not only monitor them), configure [commander for the motor](https://docs.simplefoc.com/commander_interface) 17 | - Go to [simplefoc.besson.co](https://simplefoc.besson.co) with a Chromium browser (or any browser with [WebSerial](https://caniuse.com/web-serial)) 18 | - Click on "Connect" and select your micro-controller 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SimpleFOC Controller 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /misc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekuillaume/simplefoc-webcontroller/58cd39283e49bd4b154915d750bc280bc3e10790/misc/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplefoc-webcontroller", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@emotion/react": "^11.9.0", 12 | "@emotion/styled": "^11.8.1", 13 | "@fontsource/roboto": "^4.5.7", 14 | "@mui/icons-material": "^5.8.2", 15 | "@mui/material": "^5.8.2", 16 | "eventemitter3": "^4.0.7", 17 | "lodash-es": "^4.17.21", 18 | "plotly.js": "^2.16.3", 19 | "react": "^18.0.0", 20 | "react-dom": "^18.0.0", 21 | "react-plotly.js": "^2.6.0", 22 | "react-window": "^1.8.8", 23 | "recharts": "^2.1.10" 24 | }, 25 | "devDependencies": { 26 | "@types/lodash-es": "^4.17.6", 27 | "@types/react": "^18.0.0", 28 | "@types/react-dom": "^18.0.0", 29 | "@types/react-plotly.js": "^2.5.2", 30 | "@types/react-window": "^1.8.5", 31 | "@types/w3c-web-serial": "^1.0.2", 32 | "@vitejs/plugin-react": "^1.3.0", 33 | "typescript": "^4.6.3", 34 | "vite": "^2.9.9" 35 | }, 36 | "packageManager": "yarn@3.3.0" 37 | } 38 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | min-height: 100vh; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: calc(10px + 2vmin); 23 | color: white; 24 | } 25 | 26 | .App-link { 27 | color: #61dafb; 28 | } 29 | 30 | @keyframes App-logo-spin { 31 | from { 32 | transform: rotate(0deg); 33 | } 34 | to { 35 | transform: rotate(360deg); 36 | } 37 | } 38 | 39 | button { 40 | font-size: calc(10px + 2vmin); 41 | } 42 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./App.css"; 3 | import { SimpleFocSerialPort } from "./simpleFoc/serial"; 4 | import Header from "./components/Header"; 5 | import { Box, Stack, Typography } from "@mui/material"; 6 | import { SerialManager } from "./components/SerialManager"; 7 | import { Container } from "@mui/system"; 8 | import { Motors } from "./components/Motors"; 9 | import { serialPortContext } from "./lib/serialContext"; 10 | 11 | function App() { 12 | const [serial, setSerial] = useState(null); 13 | 14 | const supportSerial = typeof navigator.serial === "object"; 15 | 16 | return ( 17 | 25 |
26 | 27 | 28 | 29 | {!supportSerial && ( 30 | 31 | WebSerial is not available on your browser, please use a{" "} 32 | browser supporting it 33 | . 34 | 35 | )} 36 | {supportSerial && ( 37 | 38 | 39 | 40 | 41 | )} 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import AppBar from "@mui/material/AppBar"; 3 | import Box from "@mui/material/Box"; 4 | import Toolbar from "@mui/material/Toolbar"; 5 | import Typography from "@mui/material/Typography"; 6 | import Button from "@mui/material/Button"; 7 | import IconButton from "@mui/material/IconButton"; 8 | import MenuIcon from "@mui/icons-material/Menu"; 9 | 10 | export default function Header(props: any) { 11 | return ( 12 | 13 | 14 | 15 | {/* 22 | 23 | */} 24 | 25 | SimpleFoc WebController 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/MotorMonitorGraph.tsx: -------------------------------------------------------------------------------- 1 | import { Axis } from "plotly.js"; 2 | import { useRef, useState } from "react"; 3 | import Plot, { Figure } from "react-plotly.js"; 4 | import { useSerialLineEvent } from "../lib/useSerialLineEvent"; 5 | 6 | const MAX_POINTS = 100; 7 | const X_SCALE = new Array(MAX_POINTS).fill(0).map((x, i) => i); 8 | 9 | const COLORS = ["red", "green", "blue", "orange", "pink"]; 10 | 11 | export const MotorMonitorGraph = ({ motorKey }: { motorKey: string }) => { 12 | const metrics = useRef([] as { name: string; data: number[] }[]); 13 | const [revision, setRevision] = useState(0); 14 | const [axisZooms, setAxisZooms] = useState({ 15 | xaxis: undefined as undefined | number[], 16 | yaxis: [] as (undefined | number[])[], 17 | }); 18 | 19 | useSerialLineEvent((line) => { 20 | if (line.content.startsWith(`${motorKey}M`)) { 21 | const points = line.content.slice(2).split("\t").map(Number); 22 | points.forEach((point, i) => { 23 | if (!metrics.current[i]) { 24 | metrics.current[i] = { 25 | name: i.toString(), 26 | data: [], 27 | }; 28 | } 29 | metrics.current[i].data.push(point); 30 | if (metrics.current[i].data.length > MAX_POINTS) { 31 | metrics.current[i].data.splice( 32 | 0, 33 | metrics.current[i].data.length - MAX_POINTS 34 | ); 35 | } 36 | }); 37 | setRevision((r) => r + 1); 38 | } 39 | }); 40 | 41 | const handleGraphUpdate = (update: Readonly
) => { 42 | let newZoom: typeof axisZooms = { 43 | xaxis: update.layout.xaxis?.autorange 44 | ? undefined 45 | : update.layout.xaxis?.range, 46 | yaxis: [], 47 | }; 48 | 49 | let hasChanged = axisZooms.xaxis !== newZoom.xaxis; 50 | 51 | metrics.current.map((m, i) => { 52 | const yAxis = (update.layout as any)[ 53 | `yaxis${i === 0 ? "" : i + 1}` 54 | ] as Partial; 55 | 56 | const zoom = yAxis?.autorange ? undefined : yAxis?.range; 57 | newZoom.yaxis.push(zoom); 58 | if (zoom !== axisZooms.yaxis[i]) { 59 | hasChanged = true; 60 | } 61 | }); 62 | 63 | if (hasChanged) { 64 | setAxisZooms(newZoom); 65 | } 66 | }; 67 | 68 | const axisData = { 69 | xaxis: { 70 | autoRange: axisZooms.xaxis, 71 | }, 72 | } as any; 73 | metrics.current.forEach((m, i) => { 74 | const range = axisZooms.yaxis[i]; 75 | axisData[`yaxis${i === 0 ? "" : i + 1}`] = { 76 | autoRange: !range, 77 | range: range, 78 | tickfront: { 79 | color: COLORS[i], 80 | }, 81 | titlefont: { 82 | color: COLORS[i], 83 | }, 84 | // position: i * 0.1, 85 | side: i % 2 ? "left" : "right", 86 | // anchor: "free", 87 | // overlaying: "y", 88 | title: `Trace ${i}`, 89 | }; 90 | }); 91 | 92 | return ( 93 |
94 | ({ 97 | x: X_SCALE, 98 | y: metric.data, 99 | type: "scattergl", 100 | mode: "lines", 101 | yaxis: `y${i === 0 ? "" : i + 1}`, 102 | line: { 103 | color: COLORS[i], 104 | }, 105 | }))} 106 | layout={{ 107 | autosize: true, 108 | height: 400, 109 | datarevision: revision, 110 | ...axisData, 111 | }} 112 | onUpdate={handleGraphUpdate} 113 | useResizeHandler 114 | style={{ 115 | width: "100%", 116 | }} 117 | /> 118 |
119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/Motors.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Card, 4 | CardContent, 5 | CardHeader, 6 | CircularProgress, 7 | Stack, 8 | Typography, 9 | } from "@mui/material"; 10 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 11 | import { 12 | Accordion, 13 | AccordionDetails, 14 | AccordionSummary, 15 | } from "./ParametersAccordion"; 16 | import { red } from "@mui/material/colors"; 17 | import { useState } from "react"; 18 | import { useSerialIntervalSender } from "../lib/useSerialIntervalSender"; 19 | import { useSerialLineEvent } from "../lib/useSerialLineEvent"; 20 | import { FocBoolean } from "./Parameters/FocBoolean"; 21 | import { FocScalar } from "./Parameters/FocScalar"; 22 | import { MotorMonitorGraph } from "./MotorMonitorGraph"; 23 | import { useSerialPortOpenStatus } from "../lib/serialContext"; 24 | import { MotorControlTypeSwitch } from "./Parameters/MotorControlTypeSwitch"; 25 | 26 | const MOTOR_OUTPUT_REGEX = /^\?(\w):(.*)\r?$/; 27 | 28 | export const Motors = () => { 29 | const [motors, setMotors] = useState<{ [key: string]: string }>({}); 30 | const portOpen = useSerialPortOpenStatus(); 31 | 32 | useSerialIntervalSender("?", 10000); 33 | useSerialLineEvent((line) => { 34 | const match = line.content.match(MOTOR_OUTPUT_REGEX); 35 | if (match) { 36 | setMotors((m) => ({ 37 | ...m, 38 | [match[1]]: match[2], 39 | })); 40 | } 41 | }); 42 | 43 | if (!Object.keys(motors).length) { 44 | if (!portOpen) { 45 | return ( 46 | 47 | 48 | Waiting for connection... 49 | 50 | 51 | ); 52 | } 53 | return ( 54 | 55 | 56 | 57 | Waiting for motors list from controller... 58 | 59 | 60 | Make sure to use "machine_readable" verbose mode 61 | 62 | 63 | ); 64 | } 65 | 66 | return ( 67 | 68 | {Object.entries(motors).map(([key, name]) => ( 69 | 70 | {name}} 72 | avatar={{key}} 73 | action={ 74 |
75 | 84 |
85 | } 86 | /> 87 | 88 | 89 | }> 90 | Control 91 | 92 | 93 | 94 | 95 | 103 | 111 | 112 | 113 | 114 | }> 115 | Velocity PID 116 | 117 | 118 | 126 | 134 | 142 | 150 | 158 | 166 | 167 | 168 | 169 | }> 170 | Angle PID 171 | 172 | 173 | 181 | 189 | 190 | 191 | 192 | 193 |
194 | ))} 195 |
196 | ); 197 | }; 198 | -------------------------------------------------------------------------------- /src/components/Parameters/FocBoolean.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Switch, Typography } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { useSerialPort } from "../../lib/serialContext"; 4 | import { useSerialIntervalSender } from "../../lib/useSerialIntervalSender"; 5 | import { useSerialLineEvent } from "../../lib/useSerialLineEvent"; 6 | import { SimpleFocSerialPort } from "../../simpleFoc/serial"; 7 | 8 | export const FocBoolean = (props: { 9 | label: string; 10 | motorKey: string; 11 | onLabel: string; 12 | offLabel: string; 13 | command: string; 14 | onValue: string; 15 | offValue: string; 16 | }) => { 17 | const fullCommandString = `${props.motorKey}${props.command}`; 18 | 19 | const [value, setValue] = useState(false); 20 | const serialPort = useSerialPort(); 21 | useSerialLineEvent((line) => { 22 | if (line.content.startsWith(fullCommandString)) { 23 | const receivedValue = line.content.slice(fullCommandString.length); 24 | if (receivedValue !== props.onValue && receivedValue !== props.offValue) { 25 | console.warn( 26 | `Received value for motor ${props.motorKey} and command ${props.command} which doesn't match on or off value: ${line.content}`, 27 | { onValue: props.onValue, offValue: props.offValue } 28 | ); 29 | return; 30 | } 31 | setValue(receivedValue === props.onValue ? true : false); 32 | } 33 | }); 34 | 35 | const onChange = (event: React.ChangeEvent) => { 36 | serialPort?.send( 37 | `${fullCommandString}${ 38 | event.target.checked ? props.onValue : props.offValue 39 | }` 40 | ); 41 | setValue(event.target.checked); 42 | }; 43 | 44 | useSerialIntervalSender(fullCommandString, 5000); 45 | 46 | return ( 47 | 48 | {props.offLabel} 49 | 50 | {props.onLabel} 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Parameters/FocScalar.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Slider, TextField, Typography } from "@mui/material"; 2 | import { 3 | Accordion, 4 | AccordionDetails, 5 | AccordionSummary, 6 | } from "../ParametersAccordion"; 7 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 8 | import { throttle } from "lodash-es"; 9 | import { useMemo, useState } from "react"; 10 | import { useSerialPortRef } from "../../lib/serialContext"; 11 | import { useSerialIntervalSender } from "../../lib/useSerialIntervalSender"; 12 | import { useSerialLineEvent } from "../../lib/useSerialLineEvent"; 13 | import { useParameterSettings } from "../../lib/useParameterSettings"; 14 | 15 | export const FocScalar = (props: { 16 | motorKey: string; 17 | command: string; 18 | label: string; 19 | defaultMin: number; 20 | defaultMax: number; 21 | step: number; 22 | }) => { 23 | const fullCommandString = `${props.motorKey}${props.command}`; 24 | const { expanded, setExpanded, min, setMin, max, setMax } = 25 | useParameterSettings(fullCommandString, props.defaultMin, props.defaultMax); 26 | 27 | const [targetValue, setTargetValue] = useState(null); // value sent to controller 28 | const [value, setValue] = useState(null); // value acknowledged by controller, for now not used 29 | const serialRef = useSerialPortRef(); 30 | 31 | useSerialLineEvent((line) => { 32 | if (line.content.startsWith(fullCommandString)) { 33 | const receivedValue = Number( 34 | line.content.slice(fullCommandString.length) 35 | ); 36 | if (!isNaN(receivedValue)) { 37 | setValue(receivedValue); 38 | if (targetValue === null) { 39 | setTargetValue(receivedValue); 40 | } 41 | } 42 | } 43 | }); 44 | useSerialIntervalSender(fullCommandString, 3000); 45 | 46 | const changeValue = useMemo( 47 | () => 48 | throttle((value: number) => { 49 | serialRef.current?.send(`${fullCommandString}${value}`); 50 | }, 200), 51 | [] 52 | ); 53 | 54 | const handleSliderChange = (e: any) => { 55 | if (e.target.value === 0 && targetValue === null) { 56 | return; 57 | } 58 | setTargetValue(e.target.value); 59 | changeValue(e.target.value); 60 | }; 61 | 62 | return ( 63 | setExpanded(isExpanded)} 66 | sx={{ backgroundColor: "grey.50" }} 67 | > 68 | } 70 | sx={{ alignItems: "center" }} 71 | > 72 | {props.label} 73 |
74 | 81 | 82 | 83 | 84 | 85 | setMin(Number(e.target.value))} 88 | size="small" 89 | type="number" 90 | variant="standard" 91 | inputProps={{ style: { textAlign: "center" } }} 92 | sx={{ width: 70 }} 93 | /> 94 | 95 | 96 | 105 | 106 | 107 | setMax(Number(e.target.value))} 110 | size="small" 111 | type="number" 112 | variant="standard" 113 | inputProps={{ style: { textAlign: "center" } }} 114 | sx={{ width: 70 }} 115 | /> 116 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/components/Parameters/MotorControlTypeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Stack, 3 | ToggleButton, 4 | ToggleButtonGroup, 5 | Typography, 6 | } from "@mui/material"; 7 | import { useState } from "react"; 8 | import { useSerialPortRef } from "../../lib/serialContext"; 9 | import { useSerialIntervalSender } from "../../lib/useSerialIntervalSender"; 10 | import { useSerialLineEvent } from "../../lib/useSerialLineEvent"; 11 | 12 | const CONTROL_VALUES = ["torque", "vel", "angle", "vel open", "angle open"]; 13 | 14 | const CONTROL_VALUE_TO_INDEX = { 15 | torque: 0, 16 | vel: 1, 17 | angle: 2, 18 | "vel open": 3, 19 | "angle open": 4, 20 | } as any; 21 | 22 | export const MotorControlTypeSwitch = ({ motorKey }: { motorKey: string }) => { 23 | const fullCommandString = `${motorKey}C`; 24 | const [value, setValue] = useState(null); 25 | const serialRef = useSerialPortRef(); 26 | 27 | const handleChange = (e: any, val: string) => { 28 | serialRef.current?.send( 29 | `${fullCommandString}${CONTROL_VALUE_TO_INDEX[val]}` 30 | ); 31 | }; 32 | 33 | useSerialLineEvent((line) => { 34 | if ( 35 | line.content.startsWith(fullCommandString) && 36 | // need to filter out the downsample command too which is "{motorKey}CD" 37 | CONTROL_VALUES.map((val) => fullCommandString + val).some( 38 | (val) => line.content === val 39 | ) 40 | ) { 41 | const receivedValue = line.content.slice(fullCommandString.length); 42 | setValue(receivedValue); 43 | console.log(receivedValue); 44 | } 45 | }); 46 | useSerialIntervalSender(fullCommandString, 5000); 47 | 48 | return ( 49 | 50 | 51 | Torque 52 | Velocity 53 | Angle 54 | Velocity open 55 | Angle open 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/ParametersAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material/styles"; 2 | import ArrowForwardIosSharpIcon from "@mui/icons-material/ArrowForwardIosSharp"; 3 | import MuiAccordion, { AccordionProps } from "@mui/material/Accordion"; 4 | import MuiAccordionSummary, { 5 | AccordionSummaryProps, 6 | } from "@mui/material/AccordionSummary"; 7 | import MuiAccordionDetails from "@mui/material/AccordionDetails"; 8 | 9 | export const Accordion = styled((props: AccordionProps) => ( 10 | 11 | ))(({ theme }) => ({ 12 | border: `1px solid ${theme.palette.divider}`, 13 | "&:not(:last-child)": { 14 | borderBottom: 0, 15 | }, 16 | "&:before": { 17 | display: "none", 18 | }, 19 | })); 20 | 21 | export const AccordionSummary = styled((props: AccordionSummaryProps) => ( 22 | } 24 | {...props} 25 | /> 26 | ))(({ theme }) => ({ 27 | backgroundColor: 28 | theme.palette.mode === "dark" 29 | ? "rgba(255, 255, 255, .05)" 30 | : "rgba(0, 0, 0, .03)", 31 | flexDirection: "row-reverse", 32 | "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { 33 | transform: "rotate(90deg)", 34 | }, 35 | "& .MuiAccordionSummary-content": { 36 | marginLeft: theme.spacing(1), 37 | }, 38 | })); 39 | 40 | export const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ 41 | padding: theme.spacing(2), 42 | borderTop: "1px solid rgba(0, 0, 0, .125)", 43 | })); 44 | -------------------------------------------------------------------------------- /src/components/SerialCommandPrompt.tsx: -------------------------------------------------------------------------------- 1 | import { Chip, Stack, TextField } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { KeyboardEventHandler, useState } from "react"; 4 | import { useSerialPort } from "../lib/serialContext"; 5 | import { SimpleFocSerialPort } from "../simpleFoc/serial"; 6 | 7 | export const SerialCommandPrompt = () => { 8 | const serial = useSerialPort(); 9 | const [promptValue, setPromptValue] = useState(""); 10 | 11 | const handleKeyDown: KeyboardEventHandler = (e) => { 12 | if (e.code === "Enter" && serial) { 13 | serial.send(promptValue); 14 | setPromptValue(""); 15 | } 16 | }; 17 | 18 | const handleStoredCommandClick = (command: string) => () => { 19 | serial?.send(command); 20 | }; 21 | 22 | const handleRestart = () => { 23 | serial?.restartTarget(); 24 | }; 25 | 26 | return ( 27 | 28 | 29 | setPromptValue(e.target.value)} 35 | onKeyDown={handleKeyDown} 36 | sx={{ flex: 1 }} 37 | /> 38 | 39 | 40 | 41 | 46 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/SerialManager.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Backdrop, 3 | Button, 4 | ButtonGroup, 5 | Card, 6 | CardContent, 7 | Chip, 8 | CircularProgress, 9 | MenuItem, 10 | Stack, 11 | TextField, 12 | Typography, 13 | } from "@mui/material"; 14 | import { Box } from "@mui/system"; 15 | import { useState } from "react"; 16 | import { useAvailablePorts } from "../lib/useAvailablePorts"; 17 | import { SimpleFocSerialPort } from "../simpleFoc/serial"; 18 | import { SerialCommandPrompt } from "./SerialCommandPrompt"; 19 | import { SerialOutputViewer } from "./SerialOutputViewer"; 20 | 21 | const BAUD_RATES = 22 | [ 23 | 300, 24 | 1200, 25 | 2400, 26 | 4800, 27 | 9600, 28 | 11200, 29 | 19200, 30 | 38400, 31 | 57600, 32 | 74880, 33 | 115200, 34 | 230400, 35 | 250000, 36 | 921600 37 | ]; 38 | 39 | export const SerialManager = ({ 40 | onSetSerial, 41 | serial, 42 | ...other 43 | }: { 44 | serial: SimpleFocSerialPort | null; 45 | onSetSerial: (serial: SimpleFocSerialPort | null) => any; 46 | }) => { 47 | const [baudRate, setBaudRate] = useState(BAUD_RATES[1]); 48 | const [loading, setLoading] = useState(false); 49 | const ports = useAvailablePorts(); 50 | 51 | const handleConnect = async (port?: SerialPort) => { 52 | const serial = new SimpleFocSerialPort(baudRate); 53 | setLoading(true); 54 | try { 55 | await serial.open(port); 56 | onSetSerial(serial); 57 | } finally { 58 | setLoading(false); 59 | } 60 | }; 61 | 62 | const handleDisconnect = async () => { 63 | setLoading(true); 64 | try { 65 | await serial?.close(); 66 | } finally { 67 | setLoading(false); 68 | } 69 | }; 70 | 71 | return ( 72 | 73 | theme.zIndex.drawer + 1 }} 75 | open={loading} 76 | > 77 | 78 | 79 | 80 | 81 | 82 | 83 | Serial 84 | 85 | setBaudRate(Number(e.target.value))} 90 | > 91 | {BAUD_RATES.map((option) => ( 92 | 93 | {option} 94 | 95 | ))} 96 | 97 | 98 | 105 | 112 | 113 | {!!ports.length && ( 114 | 115 | 116 | Previously connected: 117 | 118 | {ports.map((port, i) => ( 119 | handleConnect(port)} 127 | /> 128 | ))} 129 | 130 | )} 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/components/SerialOutputViewer.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/system"; 2 | import { FixedSizeList } from "react-window"; 3 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; 4 | import { useSerialPort, useSerialPortLines } from "../lib/serialContext"; 5 | import { SerialLine } from "../simpleFoc/serial"; 6 | 7 | const SerialLineDisplay = ({ 8 | index, 9 | style, 10 | data, 11 | }: { 12 | index: number; 13 | style: any; 14 | data: SerialLine[]; 15 | }) => ( 16 |
25 | {data[index].type === "received" ? "➡️" : "⬅️"} 26 |   27 | {data[index].content} 28 |
29 | ); 30 | 31 | const serialLinesToKey = (index: number, data: SerialLine[]) => { 32 | return data[index].index; 33 | }; 34 | 35 | const SerialLinesList = FixedSizeList; 36 | 37 | export const SerialOutputViewer = () => { 38 | const listRef = useRef(); 39 | const listOuterRef = useRef(); 40 | const lines = useSerialPortLines(); 41 | 42 | useEffect(() => { 43 | if (!listRef.current) { 44 | return; 45 | } 46 | if ( 47 | listOuterRef.current && 48 | listOuterRef.current?.scrollHeight - 49 | (listOuterRef.current?.scrollTop + listOuterRef.current?.clientHeight) < 50 | 300 51 | ) { 52 | listRef.current.scrollToItem(lines.length ? lines.length - 1 : 0); 53 | } 54 | }, [lines]); 55 | 56 | return ( 57 | 63 | 73 | 83 | {SerialLineDisplay} 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/LineBreakTransformer.ts: -------------------------------------------------------------------------------- 1 | export class LineBreakTransformer implements Transformer { 2 | chunks = ""; 3 | 4 | transform: TransformerTransformCallback = ( 5 | chunk, 6 | controller 7 | ) => { 8 | // Append new chunks to existing chunks. 9 | this.chunks += chunk; 10 | // For each line breaks in chunks, send the parsed lines out. 11 | const lines = this.chunks.split("\r\n"); 12 | this.chunks = lines.pop() || ""; 13 | lines.forEach((line) => controller.enqueue(line)); 14 | }; 15 | 16 | flush: TransformerFlushCallback = (controller) => { 17 | // When the stream is closed, flush any remaining chunks out. 18 | controller.enqueue(this.chunks); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/serialContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useRef } from "react"; 2 | import { SimpleFocSerialPort } from "../simpleFoc/serial"; 3 | import { useLastValue } from "./useLastValue"; 4 | import { useRerender, useThrotthledRerender } from "./utils"; 5 | 6 | export const serialPortContext = createContext( 7 | null 8 | ); 9 | 10 | export const useSerialPort = () => { 11 | return useContext(serialPortContext); 12 | }; 13 | 14 | export const useSerialPortLines = () => { 15 | const serial = useContext(serialPortContext); 16 | const rerender = useThrotthledRerender(100); 17 | 18 | useEffect(() => { 19 | if (!serial) { 20 | return; 21 | } 22 | const lineHandler = () => { 23 | rerender(); 24 | }; 25 | serial.on("line", lineHandler); 26 | 27 | return () => { 28 | serial.off("line", lineHandler); 29 | }; 30 | }, [serial]); 31 | return serial?.lines || []; 32 | }; 33 | 34 | export const useSerialPortRef = () => { 35 | return useLastValue(useContext(serialPortContext)); 36 | }; 37 | 38 | export const useSerialPortOpenStatus = () => { 39 | const serialPort = useSerialPort(); 40 | const rerender = useRerender(); 41 | useEffect(() => { 42 | serialPort?.addListener("stateChange", rerender); 43 | return () => { 44 | serialPort?.removeListener("stateChange", rerender); 45 | }; 46 | }, []); 47 | 48 | return !!serialPort?.port; 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/useAvailablePorts.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | export const useAvailablePorts = () => { 4 | const [ports, setPorts] = useState([] as SerialPort[]); 5 | 6 | const refreshPorts = useCallback(() => { 7 | navigator.serial 8 | .getPorts() 9 | .then((ports) => { 10 | setPorts(ports); 11 | }) 12 | .catch((e) => { 13 | console.error("Error while listing ports", e); 14 | }); 15 | }, []); 16 | 17 | useEffect(() => { 18 | navigator.serial.addEventListener("connect", refreshPorts); 19 | navigator.serial.addEventListener("disconnect", refreshPorts); 20 | refreshPorts(); 21 | return () => { 22 | navigator.serial.removeEventListener("connect", refreshPorts); 23 | navigator.serial.removeEventListener("disconnect", refreshPorts); 24 | }; 25 | }, []); 26 | 27 | return ports; 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/useInterval.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useLastValue } from "./useLastValue"; 3 | 4 | export const useInterval = (callback: () => any, interval: number) => { 5 | const callbackRef = useLastValue(callback); 6 | useEffect(() => { 7 | const intervalRef = setInterval(() => { 8 | callbackRef.current(); 9 | }, interval); 10 | return () => { 11 | clearInterval(intervalRef); 12 | }; 13 | }, [interval]); 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/useLastValue.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | export function useLastValue(value: T): { current: T } { 4 | const ref = useRef(value); 5 | ref.current = value; 6 | return ref; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/useParameterSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const useParameterSettings = ( 4 | fullCommandString: string, 5 | defaultMin: number, 6 | defaultMax: number 7 | ) => { 8 | const [expanded, setExpanded] = useLocalStorage( 9 | `simpleFocSetting:${fullCommandString}`, 10 | false 11 | ); 12 | const [min, setMin] = useLocalStorage( 13 | `simpleFocSetting:${fullCommandString}:min`, 14 | defaultMin 15 | ); 16 | const [max, setMax] = useLocalStorage( 17 | `simpleFocSetting:${fullCommandString}:max`, 18 | defaultMax 19 | ); 20 | 21 | return { 22 | expanded, 23 | setExpanded, 24 | min, 25 | setMin, 26 | max, 27 | setMax, 28 | }; 29 | }; 30 | 31 | // Hook 32 | function useLocalStorage(key: string, initialValue: T) { 33 | // State to store our value 34 | // Pass initial state function to useState so logic is only executed once 35 | const [storedValue, setStoredValue] = useState(() => { 36 | if (typeof window === "undefined") { 37 | return initialValue; 38 | } 39 | 40 | try { 41 | // Get from local storage by key 42 | const item = window.localStorage.getItem(key); 43 | // Parse stored json or if none return initialValue 44 | return item ? JSON.parse(item) : initialValue; 45 | } catch (error) { 46 | // If error also return initialValue 47 | console.log(error); 48 | return initialValue; 49 | } 50 | }); 51 | 52 | // Return a wrapped version of useState's setter function that ... 53 | // ... persists the new value to localStorage. 54 | const setValue = (value: T) => { 55 | try { 56 | // Allow value to be a function so we have same API as useState 57 | const valueToStore = 58 | value instanceof Function ? value(storedValue) : value; 59 | // Save state 60 | setStoredValue(valueToStore); 61 | // Save to local storage 62 | if (typeof window !== "undefined") { 63 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 64 | } 65 | } catch (error) { 66 | // A more advanced implementation would handle the error case 67 | console.log(error); 68 | } 69 | }; 70 | 71 | return [storedValue, setValue] as [typeof storedValue, typeof setValue]; 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/useSerialIntervalSender.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useSerialPort, useSerialPortOpenStatus } from "./serialContext"; 3 | 4 | // send the command every X miliseconds and directly after the serial is connected 5 | export const useSerialIntervalSender = (command: string, interval: number) => { 6 | const serial = useSerialPort(); 7 | const status = useSerialPortOpenStatus(); 8 | 9 | useEffect(() => { 10 | if (!status) { 11 | return; 12 | } 13 | 14 | serial?.send(command); 15 | const intervalRef = setInterval(() => { 16 | serial?.send(command); 17 | }, interval); 18 | return () => { 19 | clearInterval(intervalRef); 20 | }; 21 | }, [serial, command, interval, status]); 22 | }; 23 | -------------------------------------------------------------------------------- /src/lib/useSerialLineEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { SerialLine } from "../simpleFoc/serial"; 3 | import { useSerialPort } from "./serialContext"; 4 | 5 | export const useSerialLineEvent = (callback: (line: SerialLine) => any) => { 6 | const serial = useSerialPort(); 7 | const callbackRef = useRef(callback); 8 | callbackRef.current = callback; 9 | 10 | useEffect(() => { 11 | if (!serial) { 12 | return; 13 | } 14 | 15 | const lineHandler = (line: SerialLine) => { 16 | if (line.type === "received") { 17 | callbackRef.current(line); 18 | } 19 | }; 20 | serial.on("line", lineHandler); 21 | return () => { 22 | serial.off("line", lineHandler); 23 | }; 24 | }, [serial]); 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from "lodash-es"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | 4 | export function assertExist(a: T | undefined): asserts a is T { 5 | if (!a) { 6 | throw new Error("Undefined"); 7 | } 8 | } 9 | 10 | export function assert(a: any, msg: string = "assert failed"): void { 11 | if (!a) { 12 | throw new Error(msg); 13 | } 14 | } 15 | 16 | export const useRerender = () => { 17 | const [, setNotUsed] = useState(0); 18 | return useCallback(() => { 19 | setNotUsed((n) => n + 1); 20 | }, []); 21 | }; 22 | 23 | export const useThrotthledRerender = (delay: number) => { 24 | const [, setNotUsed] = useState(0); 25 | return useMemo(() => { 26 | return throttle(() => { 27 | setNotUsed((n) => n + 1); 28 | }, delay); 29 | }, [delay]); 30 | }; 31 | 32 | export const delay = (ms: number) => { 33 | return new Promise((resolve) => setTimeout(resolve, ms)); 34 | }; 35 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | import "@fontsource/roboto/300.css"; 7 | import "@fontsource/roboto/400.css"; 8 | import "@fontsource/roboto/500.css"; 9 | import "@fontsource/roboto/700.css"; 10 | 11 | import CssBaseline from "@mui/material/CssBaseline"; 12 | 13 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 14 | 15 | const darkTheme = createTheme({ 16 | palette: { 17 | mode: "light", 18 | }, 19 | }); 20 | 21 | ReactDOM.createRoot(document.getElementById("root")!).render( 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /src/simpleFoc/serial.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { LineBreakTransformer } from "../lib/LineBreakTransformer"; 3 | import { assert, assertExist, delay } from "../lib/utils"; 4 | 5 | const MAX_LINES_IN_BUFFER = 10000000; 6 | 7 | export type SerialLine = { 8 | index: number; 9 | content: string; 10 | type: "received" | "sent"; 11 | }; 12 | 13 | export class SimpleFocSerialPort extends EventEmitter<"line" | "stateChange"> { 14 | port: SerialPort | undefined; 15 | writer: WritableStreamDefaultWriter | undefined; 16 | 17 | _closeReader: undefined | (() => any); 18 | _closeWriter: undefined | (() => any); 19 | 20 | lastLineIndex = 0; 21 | lines = [] as SerialLine[]; 22 | 23 | constructor(public baudRate: number) { 24 | super(); 25 | } 26 | 27 | async open(existingPort?: SerialPort) { 28 | assert(!this.port, "Port is already open"); 29 | const port = 30 | existingPort || 31 | (await navigator.serial.requestPort({ 32 | filters: [], 33 | })); 34 | await port.open({ 35 | baudRate: this.baudRate, 36 | bufferSize: 1024, 37 | }); 38 | 39 | assert(!this.port, "Port is already open"); 40 | this.port = port; 41 | 42 | if (port.writable) { 43 | const textEncoder = new TextEncoderStream(); 44 | const writableStreamClosed = textEncoder.readable.pipeTo(port.writable); 45 | this.writer = textEncoder.writable.getWriter(); 46 | 47 | this._closeWriter = async () => { 48 | this.writer?.close(); 49 | await writableStreamClosed; 50 | this._closeWriter = undefined; 51 | }; 52 | } 53 | this._readLoop(); 54 | this.emit("stateChange"); 55 | } 56 | 57 | private async _readLoop() { 58 | assertExist(this.port); 59 | while (this.port?.readable) { 60 | // will restart a loop if a non-fatal error was triggered 61 | const textDecoder = new TextDecoderStream(); 62 | const readableStreamClosed = this.port.readable.pipeTo( 63 | textDecoder.writable 64 | ); 65 | 66 | const reader = textDecoder.readable 67 | .pipeThrough(new TransformStream(new LineBreakTransformer())) 68 | .getReader(); 69 | 70 | this._closeReader = async () => { 71 | await reader.cancel(); 72 | await readableStreamClosed.catch(() => {}); 73 | this._closeReader = undefined; 74 | }; 75 | 76 | try { 77 | while (true) { 78 | const { value, done } = await reader.read(); 79 | if (done) { 80 | break; 81 | } 82 | this.handleLine(value, "received"); 83 | } 84 | } catch (error) { 85 | console.error(error); 86 | // Handle |error|... 87 | } finally { 88 | reader.releaseLock(); 89 | } 90 | } 91 | } 92 | 93 | async close() { 94 | if (!this.port) { 95 | throw new Error("Already closed"); 96 | } 97 | const port = this.port; 98 | this.port = undefined; 99 | 100 | await this._closeReader?.(); 101 | await this._closeWriter?.(); 102 | 103 | await port.close(); 104 | this.emit("stateChange"); 105 | } 106 | 107 | private handleLine(line: string, type: SerialLine["type"]) { 108 | const serialLine: SerialLine = { 109 | index: this.lastLineIndex, 110 | content: line, 111 | type, 112 | }; 113 | this.lastLineIndex += 1; 114 | this.lines.push(serialLine); 115 | this.lines = this.lines.slice(-MAX_LINES_IN_BUFFER); 116 | this.emit("line", serialLine); 117 | } 118 | 119 | async send(line: string) { 120 | this.writer?.write(`${line}\n`); 121 | this.handleLine(line, "sent"); 122 | } 123 | 124 | async restartTarget() { 125 | assertExist(this.port); 126 | await this.port.setSignals({ dataTerminalReady: false }); 127 | await delay(200); 128 | await this.port.setSignals({ dataTerminalReady: true }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------