Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
95f4b16
feature: webserial
lebalz Feb 25, 2026
3dfc33c
update latest opus edits
lebalz Feb 25, 2026
c356c95
add docs
lebalz Feb 26, 2026
e8b99f0
add src transformer
lebalz Feb 27, 2026
15cafdd
ensure image editor works for package location too
lebalz Feb 27, 2026
ea37b7d
support multiple devices
lebalz Feb 27, 2026
50314be
some refactorings
lebalz Feb 27, 2026
f68b4c9
stabilize serial device
lebalz Feb 28, 2026
0188fce
add docs
lebalz Feb 28, 2026
a924500
update docs
lebalz Feb 28, 2026
210cf8a
add more docs
lebalz Feb 28, 2026
7d4cac0
add bin decoder example
lebalz Feb 28, 2026
fdf6711
update bit styling
lebalz Mar 1, 2026
7b8af4f
refactor package setup
lebalz Mar 1, 2026
a988fc3
more stats
lebalz Mar 1, 2026
42b28e8
update style of bin decoder
lebalz Mar 1, 2026
b31e06e
add replay functionality
lebalz Mar 1, 2026
cd6df33
pad hex numbers
lebalz Mar 1, 2026
cbf6d12
support fullscreen mode
lebalz Mar 1, 2026
ffdda2d
add docs
lebalz Mar 1, 2026
f4fc508
remove console.log
lebalz Mar 2, 2026
74d403c
make actions wrappable
lebalz Mar 2, 2026
2659ea5
support line breaks for bytes
lebalz Mar 2, 2026
1b0718c
display default string when no data available
lebalz Mar 2, 2026
43375d7
set default replay speed to 250ms
lebalz Mar 2, 2026
b9acd50
remove unused export
lebalz Mar 2, 2026
d0f4a1f
add microbit section
lebalz Mar 2, 2026
2eac8f1
fix visual glitch
lebalz Mar 2, 2026
a98fb55
add link to bin decoder example
lebalz Mar 2, 2026
044f4c5
add imports to docs
lebalz Mar 2, 2026
31229c0
rename onReadyMessage to resetTrigger
lebalz Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "concurrently --raw --kill-others 'docusaurus start' 'sleep 1s && ts-node updateSync/packageDocsSync/watch.ts --src packages --dest tdev-website/docs/packages'",
"start": "concurrently --raw --kill-others 'PACKAGE_SRC=packages PACKAGE_DEST=tdev-website/docs/packages docusaurus start' 'sleep 1s && ts-node updateSync/packageDocsSync/watch.ts --src packages --dest tdev-website/docs/packages'",
"prebuild": "ts-node updateSync/packageDocsSync/preBuild.ts --src packages --dest tdev-website/docs/packages",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
Expand Down
66 changes: 66 additions & 0 deletions packages/tdev/webserial/component/ReplayControl/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.scss';
import { observer } from 'mobx-react-lite';
import Button from '@tdev-components/shared/Button';
import { mdiMotionPauseOutline, mdiMotionPlay, mdiMotionPlayOutline, mdiStopCircleOutline } from '@mdi/js';
// @ts-ignore
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
import SerialDevice from '@tdev/webserial/models/SerialDevice';
import Badge from '@tdev-components/shared/Badge';

interface Props {
device: SerialDevice;
}

const ReplayControl = observer((props: Props) => {
const { device } = props;
return (
<div className={clsx(styles.replayControls)}>
{(device.canReplay || device.isReplaying) && (
<Button
onClick={() => {
device.isReplaying
? device.pauseReplay()
: device.replay(device._replayPausedAt || 0);
}}
icon={
device.isReplaying
? mdiMotionPauseOutline
: device.isReplayPaused
? mdiMotionPlay
: mdiMotionPlayOutline
}
title={`Replay ${device.isReplaying ? 'pausieren' : 'starten'}`}
color={device.isReplaying || device.isReplayPaused ? 'blue' : undefined}
/>
)}
{(device.isReplaying || device.isReplayPaused) && (
<Button
onClick={() => device.stopReplay()}
icon={mdiStopCircleOutline}
title="Replay stoppen"
color="red"
/>
)}
{(device.isReplayPaused || device.isReplaying) && (
<div className={clsx(styles.speedControl)}>
<Slider
min={0}
max={995}
value={1000 - device.replaySpeed}
onChange={(value) => device.setReplaySpeed(1000 - (value as number))}
step={5}
style={{ flexBasis: '150px', margin: '0 15px' }}
/>
<div>
Intervall <Badge color="blue">{device.replaySpeed} ms</Badge>
</div>
</div>
)}
</div>
);
});

export default ReplayControl;
12 changes: 12 additions & 0 deletions packages/tdev/webserial/component/ReplayControl/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.replayControls {
display: flex;
align-items: center;
gap: 2px;
.speedControl {
display: flex;
flex-wrap: wrap;
flex-basis: 150px;
min-width: 150px;
justify-content: center;
}
}
261 changes: 261 additions & 0 deletions packages/tdev/webserial/component/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.scss';
import { observer } from 'mobx-react-lite';
import { useStore } from '@tdev-hooks/useStore';
import Logs from '@tdev-components/documents/CodeEditor/Editor/Footer/Logs';
import Alert from '@tdev-components/shared/Alert';
import Admonition from '@theme-original/Admonition';
import CodeBlock from '@theme-original/CodeBlock';
import { ConnectionState } from '../models/SerialDevice';
import Badge from '@tdev-components/shared/Badge';
import Card from '@tdev-components/shared/Card';
import Button from '@tdev-components/shared/Button';
import {
mdiCardRemoveOutline,
mdiCheckCircle,
mdiCloseNetwork,
mdiCloseOctagon,
mdiConnection,
mdiEjectCircle,
mdiLoading,
mdiSend,
mdiSync
} from '@mdi/js';
import Icon from '@mdi/react';
import TextInput from '@tdev-components/shared/TextInput';
// @ts-ignore
import Details from '@theme/Details';
import { DeviceContext } from '../hooks/useDeviceId';
import ReplayControl from './ReplayControl';
import { FullscreenContext } from '@tdev-hooks/useFullscreenTargetId';
import RequestFullscreen from '@tdev-components/shared/RequestFullscreen';

interface Props {
/** Override default baud rate (default: 115200) */
baudRate?: number;
deviceId?: string;
hideLogs?: boolean;
collapseLogs?: boolean;
showInput?: boolean;
inputPlaceholder?: string;
inputLabel?: string;
output?: React.ReactNode;
/**
* When this string is received from the serial device, the received data will be cleared.
*/
resetTrigger?: string;
/**
* this data can be used to simulate a device by providing an array of strings that
* will be emitted as if they were received from the serial device.
* This is useful for testing and demo purposes without needing a real serial device.
* Each string in the array represents a line of data that would be received, and
* they will be emitted with a delay between them to simulate real-time data reception.
*/
initialData?: string[];
}

const ConnectionStateMessage: Record<ConnectionState, string> = {
disconnected: 'Getrennt',
connecting: 'Verbinden…',
connected: 'Verbunden',
error: 'Fehler'
};

const ConnectionStateColor: Record<ConnectionState, string> = {
disconnected: 'gray',
connecting: 'orange',
connected: 'green',
error: 'red'
};

const ButtonIcon: Record<ConnectionState, string> = {
disconnected: mdiConnection,
connecting: mdiLoading,
connected: mdiEjectCircle,
error: mdiConnection
};
const ButtonColor: Record<ConnectionState, string> = {
disconnected: 'blue',
connecting: 'orange',
connected: 'red',
error: 'blue'
};
const BadgeIcon: Record<ConnectionState, string> = {
disconnected: mdiCloseNetwork,
connecting: mdiLoading,
connected: mdiCheckCircle,
error: mdiCloseOctagon
};
const ButtonText: Record<ConnectionState, string> = {
disconnected: 'Gerät verbinden',
connecting: 'Verbinden…',
connected: 'Verbindung trennen',
error: 'Erneut versuchen'
};

const SwitchCollapsed = observer(
({ children, collapsed, title }: { children: React.ReactNode; collapsed?: boolean; title: string }) => {
if (collapsed) {
return <Details summary={title}>{children}</Details>;
}
return <>{children}</>;
}
);

const Webserial = observer((props: Props) => {
const id = React.useId();
const defaultId = React.useId();
const { baudRate, deviceId } = props;
const viewStore = useStore('viewStore');
const webserialStore = viewStore.useStore('webserialStore');
const device = webserialStore.useDevice(deviceId ?? defaultId, baudRate ? { baudRate } : {}, {
resetTrigger: props.resetTrigger
});

const handleConnect = async () => {
await device.connect();
};

const handleDisconnect = async () => {
await webserialStore.disconnectDevice(deviceId ?? defaultId);
};

React.useEffect(() => {
if (props.initialData && props.initialData.length > 0) {
device.setReplayData(props.initialData);
}
}, [device]);

React.useEffect(() => {
return () => {
webserialStore.clearDevice(deviceId ?? defaultId);
};
}, [deviceId]);

return (
<FullscreenContext.Provider value={id}>
<div id={id} className={clsx(viewStore.isFullscreenTarget(id) && styles.fullscreen)}>
<DeviceContext.Provider value={deviceId ?? defaultId}>
<Card
classNames={{
card: clsx(styles.webserial),
body: clsx(
!device.error &&
(props.hideLogs || !(device.isConnected || device.size > 0)) &&
styles.noBody
)
}}
header={
<div className={clsx(styles.toolbar)}>
<div className={clsx(styles.actions)}>
{webserialStore.isSupported && (
<Button
onClick={device.isConnected ? handleDisconnect : handleConnect}
disabled={device.connectionState === 'connecting'}
spin={device.connectionState === 'connecting'}
icon={ButtonIcon[device.connectionState]}
color={ButtonColor[device.connectionState]}
text={ButtonText[device.connectionState]}
/>
)}
<ReplayControl device={device} />
</div>
<RequestFullscreen
targetId={id}
adminOnly
className={clsx(styles.fullscreenButton)}
/>
{device.size > 0 &&
webserialStore.isSupported &&
!(device.isReplaying || device.isReplayPaused) && (
<Button
onClick={() => device.clearReceivedData()}
icon={mdiCardRemoveOutline}
title="Empfangene Daten löschen"
/>
)}
<Badge color={ConnectionStateColor[device.connectionState]}>
<Icon
path={
device.isProcessing ? mdiSync : BadgeIcon[device.connectionState]
}
size={0.75}
horizontal={device.isProcessing}
spin={device.isProcessing}
/>
{ConnectionStateMessage[device.connectionState]}
</Badge>
</div>
}
footer={props.output}
>
{!webserialStore.isSupported && (
<Alert type="warning">
⚠️ Die Web Serial API ist nicht unterstützt. Verwenden Sie Chrome oder Edge.
</Alert>
)}
{device.error && (
<>
<Admonition
type="danger"
title="Fehler"
icon={<Icon path={mdiCloseNetwork} size={1} />}
className={styles.error}
>
<CodeBlock language="text">{device.error}</CodeBlock>
</Admonition>
{/Failed to open serial port/.test(device.error) && (
<Admonition type="info" title="Troubleshooting" className={styles.error}>
Trennen Sie das Gerät vom Computer und verbinden Sie es erneut.
</Admonition>
)}
</>
)}
{!props.hideLogs && (device.isConnected || device.size > 0) && (
<SwitchCollapsed collapsed={props.collapseLogs} title="Logs">
<Logs
messages={(device.receivedData[device.size - 1] === ''
? device.receivedData.slice(0, -1)
: device.receivedData
).map((d) => ({
type: 'log',
message: d
}))}
maxLines={25}
/>
</SwitchCollapsed>
)}
{props.showInput && device.isConnected && (
<div className={clsx(styles.input)}>
<TextInput
onChange={(text) => {
device.setInputValue(text);
}}
placeholder={props.inputPlaceholder}
label={props.inputLabel}
value={device.inputValue || ''}
onEnter={() => {
device.sendLine(device.inputValue);
device.setInputValue('');
}}
className={clsx(styles.textInput)}
labelClassName={clsx(styles.label)}
/>
<Button
onClick={() => {
device.sendLine(device.inputValue);
device.setInputValue('');
}}
icon={mdiSend}
/>
</div>
)}
</Card>
</DeviceContext.Provider>
</div>
</FullscreenContext.Provider>
);
});

export default Webserial;
Loading