├── .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 | 
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------