(() => {
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/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/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------