From 9024d4130b4896deab480b5a1e1cbf0fadc70a4f Mon Sep 17 00:00:00 2001 From: Maksim Drachov Date: Sat, 13 Jun 2026 20:08:58 +0300 Subject: [PATCH 01/15] Add can/_slcan.py --- src/pycyphal2/can/_slcan.py | 133 ++++++++++++++++++++++++++++++++++++ tests/can/test_slcan.py | 73 ++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/pycyphal2/can/_slcan.py create mode 100644 tests/can/test_slcan.py diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py new file mode 100644 index 00000000..f2381b1f --- /dev/null +++ b/src/pycyphal2/can/_slcan.py @@ -0,0 +1,133 @@ +"""SLCAN text codec for CAN frames.""" + +from __future__ import annotations + +import logging + +from ._interface import Frame + +_logger = logging.getLogger(__name__) + +_CAN_EXT_ID_MASK = (1 << 29) - 1 +_CAN_CLASSIC_MTU = 8 +_HEX_CHARS = frozenset(b"0123456789abcdefABCDEF") +_CR = 0x0D +_LF = 0x0A +_BEL = 0x07 +_MAX_LINE_LENGTH = 256 + + +def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes: + """ + Encode an extended-ID Classic CAN data frame into one SLCAN ``T`` command line. + """ + if not isinstance(identifier, int) or not (0 <= identifier <= _CAN_EXT_ID_MASK): + raise ValueError(f"Invalid CAN identifier: {identifier!r}") + payload = bytes(data) + if len(payload) > _CAN_CLASSIC_MTU: + raise ValueError(f"Invalid CAN data length: {len(payload)}") + return f"T{identifier:08X}{len(payload):1d}{payload.hex().upper()}\r".encode() + + +class SLCANParser: + """ + Incremental SLCAN parser. + + Only extended-ID data frames are returned. Unsupported or malformed input is silently dropped with debug logging. + """ + + def __init__(self, *, max_line_length: int = _MAX_LINE_LENGTH) -> None: + if max_line_length < 10: + raise ValueError(f"Invalid maximum SLCAN line length: {max_line_length}") + self._max_line_length = int(max_line_length) + self._buffer = bytearray() + self._discarding = False + + def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]: + out: list[Frame] = [] + for byte in bytes(chunk): + if byte == _BEL: + if self._buffer or self._discarding: + _logger.debug("SLCAN drop adapter error len=%d", len(self._buffer)) + self._buffer.clear() + self._discarding = False + continue + if byte in (_CR, _LF): + if self._discarding: + self._buffer.clear() + self._discarding = False + continue + if self._buffer: + frame = _parse_line(bytes(self._buffer)) + if frame is not None: + out.append(frame) + self._buffer.clear() + continue + if self._discarding: + continue + if len(self._buffer) >= self._max_line_length: + _logger.debug("SLCAN drop overlong line len>%d", self._max_line_length) + self._buffer.clear() + self._discarding = True + continue + self._buffer.append(byte) + return out + + +def _parse_line(line: bytes) -> Frame | None: + command = line[:1] + if command in (b"T", b"x"): + return _parse_classic_extended(line) + if command in (b"t", b"r", b"R"): + _logger.debug("SLCAN drop unsupported frame type cmd=%r", command) + return None + _logger.debug("SLCAN drop unknown line=%r", line) + return None + + +def _parse_classic_extended(line: bytes) -> Frame | None: + if len(line) < 10: + _logger.debug("SLCAN drop short classic line=%r", line) + return None + identifier = _parse_hex_int(line[1:9]) + dlc = _parse_classic_dlc(line[9]) + if identifier is None or dlc is None: + _logger.debug("SLCAN drop malformed classic header line=%r", line) + return None + expected = 10 + dlc * 2 + if len(line) != expected: + _logger.debug("SLCAN drop classic dlc mismatch len=%d expected=%d", len(line), expected) + return None + return _make_frame(identifier, line[10:]) + + +def _make_frame(identifier: int, data_hex: bytes) -> Frame | None: + data = _parse_hex_bytes(data_hex) + if data is None: + _logger.debug("SLCAN drop malformed data id=%08x", identifier) + return None + try: + return Frame(id=identifier, data=data) + except ValueError as ex: + _logger.debug("SLCAN drop invalid frame: %s", ex) + return None + + +def _parse_hex_int(value: bytes) -> int | None: + if not value or not _is_hex(value): + return None + return int(value, 16) + + +def _parse_hex_bytes(value: bytes) -> bytes | None: + if len(value) % 2 != 0 or not _is_hex(value): + return None + return bytes.fromhex(value.decode("ascii")) + + +def _parse_classic_dlc(value: int) -> int | None: + return value - ord("0") if ord("0") <= value <= ord("8") else None + + +def _is_hex(value: bytes) -> bool: + return all(x in _HEX_CHARS for x in value) diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py new file mode 100644 index 00000000..e38ebbea --- /dev/null +++ b/tests/can/test_slcan.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import pytest + +from pycyphal2.can import Frame +from pycyphal2.can._slcan import SLCANParser, encode_frame + + +def test_encode_classic_extended_frames() -> None: + assert encode_frame(0x123, b"") == b"T000001230\r" + assert encode_frame(0x1BADC0DE, b"\x01\xAB") == b"T1BADC0DE201AB\r" + assert encode_frame(0x1FFFFFFF, bytes(range(8))) == b"T1FFFFFFF80001020304050607\r" + + +def test_encode_validation() -> None: + with pytest.raises(ValueError, match="Invalid CAN identifier"): + encode_frame(-1, b"") + + with pytest.raises(ValueError, match="Invalid CAN data length"): + encode_frame(0x123, bytes(range(9))) + + +def test_parse_classic_extended_frames() -> None: + parser = SLCANParser() + + assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xAB\xCD")] + assert parser.feed(b"T000001") == [] + assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")] + assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xAB")] + + +def test_parse_multiple_frames_and_newlines() -> None: + parser = SLCANParser() + + assert parser.feed(b"T000000010\rT1FFFFFFF1AA\r\n") == [ + Frame(id=1, data=b""), + Frame(id=0x1FFFFFFF, data=b"\xAA"), + ] + + +def test_parse_drops_unsupported_frame_types() -> None: + parser = SLCANParser() + + assert parser.feed(b"t1231AA\rr1231\rR000001231\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")] + + +def test_parse_drops_malformed_input_without_raising() -> None: + parser = SLCANParser() + + assert parser.feed(b"T00000123XAA\r") == [] + assert parser.feed(b"T000001232AA\r") == [] + assert parser.feed(b"T000001232AABBCC\r") == [] + assert parser.feed(b"T000001232AAGG\r") == [] + assert parser.feed(b"TFFFFFFFF0\r") == [] + assert parser.feed(b"V0102\rN1234\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")] + + +def test_parser_bounds_overlong_input() -> None: + parser = SLCANParser(max_line_length=10) + + assert parser.feed(b"T00000123155") == [] + assert parser.feed(b"\rT000001230\r") == [Frame(id=0x123, data=b"")] + + +def test_parser_bel_drops_buffered_error_response() -> None: + parser = SLCANParser() + + assert parser.feed(b"T00000123155\aT00000123166\r") == [Frame(id=0x123, data=b"\x66")] + + +def test_parser_rejects_invalid_buffer_limit() -> None: + with pytest.raises(ValueError, match="Invalid maximum SLCAN line length"): + SLCANParser(max_line_length=9) From da1e3384cd3f0f665f7a5b076bafb14c231ffde0 Mon Sep 17 00:00:00 2001 From: Maksim Drachov Date: Mon, 15 Jun 2026 19:14:36 +0300 Subject: [PATCH 02/15] working --- .venv/bin/Activate.ps1 | 247 ++++++++++++++++++ .venv/bin/activate | 70 +++++ .venv/bin/activate-global-python-argcomplete | 8 + .venv/bin/activate.csh | 27 ++ .venv/bin/activate.fish | 69 +++++ .venv/bin/dependency-groups | 8 + .venv/bin/httpx | 8 + .venv/bin/idna | 8 + .venv/bin/lint-dependency-groups | 8 + .venv/bin/nox | 8 + .venv/bin/pbs-install | 8 + .venv/bin/pip | 8 + .venv/bin/pip-install-dependency-groups | 8 + .venv/bin/pip3 | 8 + .venv/bin/pip3.12 | 8 + .venv/bin/pyproject-build | 8 + .venv/bin/python | 1 + ...thon-argcomplete-check-easy-install-script | 8 + .venv/bin/python3 | 1 + .venv/bin/python3.12 | 1 + .venv/bin/register-python-argcomplete | 8 + .venv/bin/tox-to-nox | 8 + .venv/bin/virtualenv | 8 + .venv/lib64 | 1 + .venv/pyvenv.cfg | 5 + noxfile.py | 3 +- src/pycyphal2/can/__init__.py | 5 +- src/pycyphal2/can/webserial.py | 214 +++++++++++++++ tests/can/test_webserial.py | 219 ++++++++++++++++ topic-naming.md | 0 webserial_browser_smoke.md | 201 ++++++++++++++ webserial_slcan_plan.md | 186 +++++++++++++ 32 files changed, 1376 insertions(+), 2 deletions(-) create mode 100644 .venv/bin/Activate.ps1 create mode 100644 .venv/bin/activate create mode 100755 .venv/bin/activate-global-python-argcomplete create mode 100644 .venv/bin/activate.csh create mode 100644 .venv/bin/activate.fish create mode 100755 .venv/bin/dependency-groups create mode 100755 .venv/bin/httpx create mode 100755 .venv/bin/idna create mode 100755 .venv/bin/lint-dependency-groups create mode 100755 .venv/bin/nox create mode 100755 .venv/bin/pbs-install create mode 100755 .venv/bin/pip create mode 100755 .venv/bin/pip-install-dependency-groups create mode 100755 .venv/bin/pip3 create mode 100755 .venv/bin/pip3.12 create mode 100755 .venv/bin/pyproject-build create mode 120000 .venv/bin/python create mode 100755 .venv/bin/python-argcomplete-check-easy-install-script create mode 120000 .venv/bin/python3 create mode 120000 .venv/bin/python3.12 create mode 100755 .venv/bin/register-python-argcomplete create mode 100755 .venv/bin/tox-to-nox create mode 100755 .venv/bin/virtualenv create mode 120000 .venv/lib64 create mode 100644 .venv/pyvenv.cfg create mode 100644 src/pycyphal2/can/webserial.py create mode 100644 tests/can/test_webserial.py create mode 100644 topic-naming.md create mode 100644 webserial_browser_smoke.md create mode 100644 webserial_slcan_plan.md diff --git a/.venv/bin/Activate.ps1 b/.venv/bin/Activate.ps1 new file mode 100644 index 00000000..b49d77ba --- /dev/null +++ b/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/.venv/bin/activate b/.venv/bin/activate new file mode 100644 index 00000000..13cb34bd --- /dev/null +++ b/.venv/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath /home/maksim/pycyphal/.venv) +else + # use the path as-is + export VIRTUAL_ENV=/home/maksim/pycyphal/.venv +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1='(.venv) '"${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT='(.venv) ' + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/.venv/bin/activate-global-python-argcomplete b/.venv/bin/activate-global-python-argcomplete new file mode 100755 index 00000000..cb817c0f --- /dev/null +++ b/.venv/bin/activate-global-python-argcomplete @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from argcomplete.scripts.activate_global_python_argcomplete import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/activate.csh b/.venv/bin/activate.csh new file mode 100644 index 00000000..57a7dc63 --- /dev/null +++ b/.venv/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /home/maksim/pycyphal/.venv + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(.venv) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(.venv) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/.venv/bin/activate.fish b/.venv/bin/activate.fish new file mode 100644 index 00000000..d27a3487 --- /dev/null +++ b/.venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /home/maksim/pycyphal/.venv + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(.venv) ' +end diff --git a/.venv/bin/dependency-groups b/.venv/bin/dependency-groups new file mode 100755 index 00000000..c8863302 --- /dev/null +++ b/.venv/bin/dependency-groups @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from dependency_groups.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/httpx b/.venv/bin/httpx new file mode 100755 index 00000000..7d0e6ef4 --- /dev/null +++ b/.venv/bin/httpx @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/idna b/.venv/bin/idna new file mode 100755 index 00000000..d78f8d5f --- /dev/null +++ b/.venv/bin/idna @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from idna.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/lint-dependency-groups b/.venv/bin/lint-dependency-groups new file mode 100755 index 00000000..faa7c0b2 --- /dev/null +++ b/.venv/bin/lint-dependency-groups @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from dependency_groups._lint_dependency_groups import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/nox b/.venv/bin/nox new file mode 100755 index 00000000..140ce678 --- /dev/null +++ b/.venv/bin/nox @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from nox.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pbs-install b/.venv/bin/pbs-install new file mode 100755 index 00000000..c586872a --- /dev/null +++ b/.venv/bin/pbs-install @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pbs_installer.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip b/.venv/bin/pip new file mode 100755 index 00000000..1ad67de0 --- /dev/null +++ b/.venv/bin/pip @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip-install-dependency-groups b/.venv/bin/pip-install-dependency-groups new file mode 100755 index 00000000..c26892e7 --- /dev/null +++ b/.venv/bin/pip-install-dependency-groups @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from dependency_groups._pip_wrapper import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip3 b/.venv/bin/pip3 new file mode 100755 index 00000000..1ad67de0 --- /dev/null +++ b/.venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip3.12 b/.venv/bin/pip3.12 new file mode 100755 index 00000000..1ad67de0 --- /dev/null +++ b/.venv/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pyproject-build b/.venv/bin/pyproject-build new file mode 100755 index 00000000..7046ff63 --- /dev/null +++ b/.venv/bin/pyproject-build @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from build.__main__ import entrypoint +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(entrypoint()) diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 00000000..b8a0adbb --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/python-argcomplete-check-easy-install-script b/.venv/bin/python-argcomplete-check-easy-install-script new file mode 100755 index 00000000..52d6513d --- /dev/null +++ b/.venv/bin/python-argcomplete-check-easy-install-script @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from argcomplete.scripts.python_argcomplete_check_easy_install_script import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 00000000..ae65fdaa --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv/bin/python3.12 b/.venv/bin/python3.12 new file mode 120000 index 00000000..b8a0adbb --- /dev/null +++ b/.venv/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/register-python-argcomplete b/.venv/bin/register-python-argcomplete new file mode 100755 index 00000000..34dbc6fd --- /dev/null +++ b/.venv/bin/register-python-argcomplete @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from argcomplete.scripts.register_python_argcomplete import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/tox-to-nox b/.venv/bin/tox-to-nox new file mode 100755 index 00000000..5607f615 --- /dev/null +++ b/.venv/bin/tox-to-nox @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from nox.tox_to_nox import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/virtualenv b/.venv/bin/virtualenv new file mode 100755 index 00000000..2e2cba7c --- /dev/null +++ b/.venv/bin/virtualenv @@ -0,0 +1,8 @@ +#!/home/maksim/pycyphal/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from virtualenv.__main__ import run_with_catch +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(run_with_catch()) diff --git a/.venv/lib64 b/.venv/lib64 new file mode 120000 index 00000000..7951405f --- /dev/null +++ b/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 00000000..a0d48148 --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /home/maksim/pycyphal/.venv diff --git a/noxfile.py b/noxfile.py index 40c6ffcf..6ff47ea4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,11 +2,12 @@ import shutil from pathlib import Path + import nox nox.options.sessions = ["test", "mypy", "lint", "format"] -PYTHONS = ["3.11", "3.12", "3.13"] +PYTHONS = ["3.12"] @nox.session(python=False, default=False) diff --git a/src/pycyphal2/can/__init__.py b/src/pycyphal2/can/__init__.py index 9ff713d2..3990a071 100644 --- a/src/pycyphal2/can/__init__.py +++ b/src/pycyphal2/can/__init__.py @@ -24,6 +24,9 @@ transport = CANTransport.new(PythonCANInterface(bus)) ``` +For browser/Pyodide applications, use `pycyphal2.can.webserial.WebSerialSLCANInterface` with an +application-provided async WebSerial byte stream adapter. + Pass the transport to `pycyphal2.Node.new()` to start a node. For the available dependencies see the submodules such as `socketcan` et al. @@ -37,7 +40,7 @@ from ._interface import TimestampedFrame as TimestampedFrame from ._transport import CANTransport as CANTransport -# Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan; +# Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan / pycyphal2.can.webserial; # they are not eagerly imported here because they pull in optional dependencies. __all__ = ["CANTransport", "Frame", "TimestampedFrame", "Filter", "Interface"] diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py new file mode 100644 index 00000000..d37debad --- /dev/null +++ b/src/pycyphal2/can/webserial.py @@ -0,0 +1,214 @@ +"""Browser-oriented SLCAN backend for WebSerial/Pyodide.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Protocol, runtime_checkable + +from .._api import ClosedError, Instant +from ._interface import Filter, Interface, TimestampedFrame +from ._slcan import SLCANParser, encode_frame +from ._wire import match_filters + +_logger = logging.getLogger(__name__) + + +@runtime_checkable +class AsyncSerialPort(Protocol): + """Minimal async byte stream expected from a WebSerial adapter.""" + + async def read(self) -> bytes: + """Return the next received byte chunk. An empty chunk indicates end-of-stream.""" + del self + raise NotImplementedError + + async def write(self, data: bytes) -> None: + """Write one byte chunk to the serial adapter.""" + del self, data + raise NotImplementedError + + async def close(self) -> None: + """Close the serial adapter.""" + del self + raise NotImplementedError + + +class WebSerialSLCANInterface(Interface): + """ + SLCAN CAN interface over an application-provided async serial byte stream. + + The port is expected to be already opened and configured by browser/Pyodide glue code. + Only Classic CAN extended-ID data frames are supported. + """ + + def __init__(self, port: AsyncSerialPort, *, name: str = "webserial") -> None: + self._port = port + self._name = str(name) + self._closed = False + self._failure: BaseException | None = None + self._filters = [Filter.promiscuous()] + self._parser = SLCANParser() + self._tx_seq = 0 + self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue() + self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue() + self._tx_task: asyncio.Task[None] | None = None + self._rx_task: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + _logger.info("WebSerial SLCAN init iface=%s", self._name) + + @property + def name(self) -> str: + return self._name + + @property + def fd(self) -> bool: + return False + + def filter(self, filters: Iterable[Filter]) -> None: + self._raise_if_closed() + self._filters = list(filters) + _logger.debug("WebSerial SLCAN filters set iface=%s n=%d", self._name, len(self._filters)) + + def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None: + self._raise_if_closed() + chunks = tuple(bytes(item) for item in data) + for chunk in chunks: + encode_frame(id, chunk) # Validate before mutating the queue. + if self._tx_task is None: + self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop()) + for chunk in chunks: + self._tx_seq += 1 + self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, chunk)) + + def purge(self) -> None: + if self._closed: + return + dropped = 0 + try: + while True: + self._tx_queue.get_nowait() + dropped += 1 + except asyncio.QueueEmpty: + pass + if dropped > 0: + _logger.debug("WebSerial SLCAN purge iface=%s dropped=%d", self._name, dropped) + + async def receive(self) -> TimestampedFrame: + self._raise_if_closed() + if self._rx_task is None: + self._rx_task = asyncio.get_running_loop().create_task(self._rx_loop()) + item = await self._rx_queue.get() + if isinstance(item, BaseException): + if isinstance(item, ClosedError): + raise item + raise ClosedError(f"WebSerial SLCAN interface {self._name} receive failed") from item + return item + + def close(self) -> None: + self._close(ClosedError(f"WebSerial SLCAN interface {self._name} closed")) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self._name!r}, fd=False)" + + async def _tx_loop(self) -> None: + while not self._closed: + try: + identifier, seq, deadline_ns, payload = await self._tx_queue.get() + except asyncio.CancelledError: + raise + if self._closed: + return + if Instant.now().ns >= deadline_ns: + _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier) + continue + timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9) + if timeout <= 0.0: + _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier) + continue + try: + await asyncio.wait_for(self._port.write(encode_frame(identifier, payload)), timeout=timeout) + except asyncio.TimeoutError: + self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload)) + await asyncio.sleep(0.001) + except asyncio.CancelledError: + raise + except Exception as ex: + self._fail(ex) + return + + async def _rx_loop(self) -> None: + while not self._closed: + try: + chunk = await self._port.read() + except asyncio.CancelledError: + raise + except Exception as ex: + self._fail(ex) + return + if not chunk: + self._fail(EOFError(f"WebSerial SLCAN interface {self._name} ended")) + return + for frame in self._parser.feed(chunk): + if match_filters(self._filters, frame.id): + self._rx_queue.put_nowait( + TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()) + ) + + def _fail(self, ex: BaseException) -> None: + if self._failure is None: + self._failure = ex + _logger.error("WebSerial SLCAN interface %s failed: %s", self._name, ex) + self._close(ex) + + def _close(self, unblock: BaseException) -> None: + if self._closed: + return + self._closed = True + self._cancel_worker_tasks() + self._drain_rx_queue() + self._rx_queue.put_nowait(unblock) + self._close_port() + + def _cancel_worker_tasks(self) -> None: + current: asyncio.Task[object] | None + try: + current = asyncio.current_task() + except RuntimeError: + current = None + for task in (self._tx_task, self._rx_task): + if task is not None and task is not current: + task.cancel() + self._tx_task = None + self._rx_task = None + + def _close_port(self) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + try: + asyncio.run(self._close_port_async()) + except Exception as ex: + _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex) + return + self._close_task = loop.create_task(self._close_port_async()) + + async def _close_port_async(self) -> None: + try: + await self._port.close() + except Exception as ex: + _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex) + + def _raise_if_closed(self) -> None: + if self._closed: + if self._failure is not None: + raise ClosedError(f"WebSerial SLCAN interface {self._name} failed") from self._failure + raise ClosedError(f"WebSerial SLCAN interface {self._name} closed") + + def _drain_rx_queue(self) -> None: + try: + while True: + self._rx_queue.get_nowait() + except asyncio.QueueEmpty: + pass diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py new file mode 100644 index 00000000..70a9ea48 --- /dev/null +++ b/tests/can/test_webserial.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from pycyphal2 import ClosedError, Instant +from pycyphal2.can import Filter, TimestampedFrame +from pycyphal2.can.webserial import AsyncSerialPort, WebSerialSLCANInterface + + +class _FakeAsyncSerial: + def __init__(self, reads: list[bytes | BaseException] | None = None) -> None: + self.reads = list(reads or []) + self.writes: list[bytes] = [] + self.write_errors: list[BaseException] = [] + self.closed = False + self._read_waiter: asyncio.Future[bytes] | None = None + + async def read(self) -> bytes: + if self.reads: + item = self.reads.pop(0) + if isinstance(item, BaseException): + raise item + return item + loop = asyncio.get_running_loop() + self._read_waiter = loop.create_future() + return await self._read_waiter + + async def write(self, data: bytes) -> None: + self.writes.append(bytes(data)) + if self.write_errors: + item = self.write_errors.pop(0) + raise item + + async def close(self) -> None: + self.closed = True + if self._read_waiter is not None and not self._read_waiter.done(): + self._read_waiter.set_result(b"") + + def feed(self, data: bytes) -> None: + if self._read_waiter is not None and not self._read_waiter.done(): + self._read_waiter.set_result(data) + self._read_waiter = None + else: + self.reads.append(data) + + +async def _wait_for(predicate: Callable[[], bool], timeout: float = 1.0) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if predicate(): + return + await asyncio.sleep(0.001) + raise AssertionError("predicate did not become true within timeout") + + +def test_async_serial_port_protocol_runtime_check() -> None: + assert isinstance(_FakeAsyncSerial(), AsyncSerialPort) + + +def test_webserial_interface_properties_and_sync_close() -> None: + port = _FakeAsyncSerial() + iface = WebSerialSLCANInterface(port, name="slcan-web") + + assert iface.name == "slcan-web" + assert iface.fd is False + assert repr(iface) == "WebSerialSLCANInterface('slcan-web', fd=False)" + + iface.close() + iface.close() + assert port.closed is True + + +def test_enqueue_writes_expected_slcan_bytes() -> None: + async def run() -> None: + port = _FakeAsyncSerial() + iface = WebSerialSLCANInterface(port) + iface.enqueue(0x123, [memoryview(b"\xAA"), memoryview(b"")], Instant.now() + 1.0) + + await _wait_for(lambda: len(port.writes) == 2) + assert port.writes == [b"T000001231AA\r", b"T000001230\r"] + + iface.close() + await _wait_for(lambda: port.closed) + + asyncio.run(run()) + + +def test_receive_returns_timestamped_frame_and_drops_malformed_input() -> None: + async def run() -> None: + port = _FakeAsyncSerial([b"bad\rT000001231AA\r"]) + iface = WebSerialSLCANInterface(port) + + before = Instant.now() + frame = await asyncio.wait_for(iface.receive(), timeout=1.0) + after = Instant.now() + + assert frame == TimestampedFrame(id=0x123, data=b"\xAA", timestamp=frame.timestamp) + assert before.ns <= frame.timestamp.ns <= after.ns + iface.close() + + asyncio.run(run()) + + +def test_receive_applies_local_filters() -> None: + async def run() -> None: + port = _FakeAsyncSerial([b"T000001231AA\rT000004561BB\r"]) + iface = WebSerialSLCANInterface(port) + iface.filter([Filter(id=0x456, mask=0x1FFFFFFF)]) + + frame = await asyncio.wait_for(iface.receive(), timeout=1.0) + + assert frame.id == 0x456 + assert frame.data == b"\xBB" + iface.close() + + asyncio.run(run()) + + +def test_expired_deadline_is_dropped() -> None: + async def run() -> None: + port = _FakeAsyncSerial() + iface = WebSerialSLCANInterface(port) + + iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + (-1.0)) + iface.enqueue(0x124, [memoryview(b"\xBB")], Instant.now() + 1.0) + + await _wait_for(lambda: len(port.writes) == 1) + assert port.writes == [b"T000001241BB\r"] + iface.close() + + asyncio.run(run()) + + +def test_purge_drops_pending_tx() -> None: + async def run() -> None: + port = _FakeAsyncSerial() + iface = WebSerialSLCANInterface(port) + + iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + 10.0) + iface.purge() + iface.enqueue(0x124, [memoryview(b"\xBB")], Instant.now() + 1.0) + + await _wait_for(lambda: len(port.writes) == 1) + assert port.writes == [b"T000001241BB\r"] + iface.close() + + asyncio.run(run()) + + +def test_close_unblocks_pending_receive_and_operations_raise() -> None: + async def run() -> None: + port = _FakeAsyncSerial() + iface = WebSerialSLCANInterface(port) + task = asyncio.create_task(iface.receive()) + await asyncio.sleep(0) + + iface.close() + + with pytest.raises(ClosedError, match="closed"): + await asyncio.wait_for(task, timeout=1.0) + with pytest.raises(ClosedError, match="closed"): + iface.enqueue(0x123, [memoryview(b"")], Instant.now()) + with pytest.raises(ClosedError, match="closed"): + iface.filter([Filter.promiscuous()]) + iface.purge() + await _wait_for(lambda: port.closed) + + asyncio.run(run()) + + +def test_read_failure_closes_interface() -> None: + async def run() -> None: + port = _FakeAsyncSerial([OSError("rx failed")]) + iface = WebSerialSLCANInterface(port) + + with pytest.raises(ClosedError, match="receive failed") as exc_info: + await asyncio.wait_for(iface.receive(), timeout=1.0) + assert isinstance(exc_info.value.__cause__, OSError) + + with pytest.raises(ClosedError, match="failed") as closed_info: + iface.filter([Filter.promiscuous()]) + assert isinstance(closed_info.value.__cause__, OSError) + await _wait_for(lambda: port.closed) + + asyncio.run(run()) + + +def test_write_failure_closes_interface() -> None: + async def run() -> None: + port = _FakeAsyncSerial() + port.write_errors.append(OSError("tx failed")) + iface = WebSerialSLCANInterface(port) + + iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + 1.0) + + await _wait_for(lambda: port.closed) + with pytest.raises(ClosedError, match="failed") as exc_info: + iface.enqueue(0x123, [memoryview(b"")], Instant.now()) + assert isinstance(exc_info.value.__cause__, OSError) + + asyncio.run(run()) + + +def test_enqueue_validation() -> None: + async def run() -> None: + iface = WebSerialSLCANInterface(_FakeAsyncSerial()) + + with pytest.raises(ValueError, match="Invalid CAN identifier"): + iface.enqueue(-1, [memoryview(b"")], Instant.now()) + with pytest.raises(ValueError, match="Invalid CAN data length"): + iface.enqueue(0x123, [memoryview(bytes(range(9)))], Instant.now()) + + iface.close() + + asyncio.run(run()) diff --git a/topic-naming.md b/topic-naming.md new file mode 100644 index 00000000..e69de29b diff --git a/webserial_browser_smoke.md b/webserial_browser_smoke.md new file mode 100644 index 00000000..8270cd53 --- /dev/null +++ b/webserial_browser_smoke.md @@ -0,0 +1,201 @@ +# WebSerial SLCAN Browser Smoke Test + +This is a manual smoke test for `pycyphal2.can.webserial.WebSerialSLCANInterface` in a Pyodide browser runtime. +It is intentionally not part of `nox` because it depends on a browser permission prompt, WebSerial support, and real +SLCAN/CAN hardware. + +## Preconditions + +- Browser with WebSerial support, such as a Chromium-based browser. +- Secure context. `http://localhost` is acceptable for local development. +- An SLCAN-compatible serial CAN adapter connected to a real CAN bus. +- Another CAN node on the bus, or an adapter/firmware mode that echoes transmitted frames. +- A local `pycyphal2` wheel served over HTTP. + +Build and serve the wheel from the repository root: + +```bash +python -m build +python -m http.server 8000 +``` + +Open the smoke page from `http://localhost:8000/...` so the wheel and page share the same origin. + +## Smoke Page + +Use this as the page body. Adjust the Pyodide version, wheel URL, serial baud rate, and SLCAN bitrate command for the +adapter under test. + +```html + + +pycyphal2 WebSerial SLCAN Smoke + + +

+
+
+
+```
+
+## Expected Result
+
+- The browser asks for serial-port permission after the button click.
+- The adapter receives `C`, bitrate, and `O` SLCAN commands.
+- Python creates `WebSerialSLCANInterface` and `CANTransport`.
+- A Cyphal/CAN message is published on subject `1234`.
+- If another CAN node or adapter echo produces a subject `1234` transfer, the arrival is printed in the console.
+
+## Notes
+
+- Replace `./dist/pycyphal2-0.0.0-py3-none-any.whl` with the actual wheel filename in `dist/`.
+- The example configures Classic CAN only; the current SLCAN backend intentionally has no CAN FD support.
+- The interface does not configure SLCAN adapters by itself. The smoke page sends `C`, `S6`, and `O` before constructing
+  `WebSerialSLCANInterface`.
+- If receive times out, first verify that the CAN bus has another active node or that the adapter emits loopback frames.
+
+## References
+
+- MDN Web Serial API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API
+- MDN `SerialPort.open()`: https://developer.mozilla.org/en-US/docs/Web/API/SerialPort/open
+- Pyodide type translations and JS proxy behavior: https://pyodide.org/en/stable/usage/type-conversions.html
diff --git a/webserial_slcan_plan.md b/webserial_slcan_plan.md
new file mode 100644
index 00000000..09211942
--- /dev/null
+++ b/webserial_slcan_plan.md
@@ -0,0 +1,186 @@
+# WebSerial SLCAN CAN Interface Plan
+
+This plan outlines how to add and test a CAN interface backend for running `pycyphal2` inside a browser through
+Pyodide, using WebSerial to communicate with an SLCAN-compatible CAN adapter.
+
+## 1. Add a Private SLCAN Codec
+
+Create `src/pycyphal2/can/_slcan.py`.
+
+Keep this module pure Python and dependency-free. It should handle only SLCAN byte/text encoding and decoding, without
+knowing about WebSerial or `asyncio`.
+
+Suggested shape:
+
+```python
+def encode_frame(identifier: int, data: bytes) -> bytes: ...
+
+
+class SLCANParser:
+    def feed(self, chunk: bytes) -> list[Frame]: ...
+```
+
+Handle:
+
+- Classic extended data frames: `T{ID:08X}{DLC}{DATA}\r`.
+- Optional CANDapter extended alias: `x...`.
+- Drop standard frames, remote frames, malformed hex, DLC/data mismatch, and overlength payloads.
+- Bound the internal receive buffer so malformed input cannot grow memory forever.
+
+Do not copy python-can code. Use its SLCAN behavior as a reference only.
+
+## 2. Define a Minimal Async Serial Protocol
+
+Create `src/pycyphal2/can/webserial.py`.
+
+Avoid binding the interface directly to JavaScript globals. Define a small typed protocol that can be implemented by a
+Pyodide WebSerial adapter and by CPython test doubles:
+
+```python
+class AsyncSerialPort(Protocol):
+    async def read(self) -> bytes: ...
+
+    async def write(self, data: bytes) -> None: ...
+
+    async def close(self) -> None: ...
+```
+
+Then implement:
+
+```python
+class WebSerialSLCANInterface(Interface):
+    def __init__(self, port: AsyncSerialPort, *, name: str = "webserial") -> None: ...
+```
+
+This keeps the backend testable under normal CPython and avoids requiring browser objects at import time.
+
+## 3. Implement Interface Semantics
+
+Mirror the general structure of `src/pycyphal2/can/pythoncan.py`, but keep it fully async and thread-free.
+
+Core behavior:
+
+- `name`: return the configured interface name.
+- `fd`: return `False`.
+- `filter(filters)`: store filters locally; SLCAN has no portable acceptance-filter command.
+- `enqueue(id, data, deadline)`: push frames into an `asyncio.PriorityQueue`.
+- `_tx_loop()`: pop queued frames, enforce deadlines, encode SLCAN, and `await port.write(...)`.
+- RX path: read serial chunks, feed the parser, locally apply filters, and enqueue `TimestampedFrame` instances.
+- `purge()`: clear queued TX frames.
+- `close()`: cancel tasks, unblock pending `receive()`, close the port, and remain idempotent.
+- Failures: record the first failure and raise `ClosedError`, following the existing backend pattern.
+
+Use `tests/can/_support.py` as the behavior model for local filter matching.
+
+## 4. Keep Browser Wiring Separate
+
+Browser permission flow should not be embedded into the importable backend.
+
+Add either:
+
+- a small `from_webserial_port(...)` classmethod, or
+- a separate example/helper module.
+
+The application should perform `navigator.serial.requestPort()` from JavaScript or Pyodide-side glue code, then pass an
+already-open async serial adapter into `WebSerialSLCANInterface`.
+
+This matters because WebSerial depends on browser support, secure context, permissions policy, and user activation.
+
+## 5. Unit-Test the Codec
+
+Add `tests/can/test_slcan.py`.
+
+Cover:
+
+- Encode empty, 1-byte, and 8-byte Classic frames.
+- Encode maximum extended ID `0x1FFFFFFF`.
+- Reject payloads larger than 8 bytes.
+- Parse fragmented input, such as `b"T000001"` followed by the rest of the line.
+- Parse multiple frames in one chunk.
+- Drop malformed lines without raising.
+- Drop standard and remote frames.
+- Drop DLC/data mismatch.
+
+These tests should be pure synchronous tests.
+
+## 6. Unit-Test the Interface With Fake Serial
+
+Add `tests/can/test_webserial.py`.
+
+Create a fake async serial port:
+
+```python
+class FakeAsyncSerial:
+    writes: list[bytes]
+
+    async def read(self) -> bytes: ...
+
+    async def write(self, data: bytes) -> None: ...
+
+    async def close(self) -> None: ...
+```
+
+Test:
+
+- `enqueue()` writes expected SLCAN bytes.
+- `receive()` returns `TimestampedFrame`.
+- Filters are applied locally.
+- Expired deadlines are dropped.
+- `purge()` drops pending TX.
+- Malformed RX input is ignored and the next valid frame is received.
+- `close()` is idempotent.
+- Pending `receive()` unblocks on close.
+- Read/write failure becomes `ClosedError`.
+
+## 7. Add Transport-Level Fake Bus Tests
+
+Build a fake SLCAN serial hub connecting two fake serial ports. When one side writes a `T...` frame, the hub feeds that
+line into the other side's read queue.
+
+Add transport tests equivalent to the important SocketCAN smoke tests:
+
+- Pub/sub smoke.
+- Unicast smoke.
+- Self-loopback behavior, if supported/configurable.
+- Node-ID reroll collision case, if the fake hub supports loopback.
+
+This verifies that `CANTransport.new(WebSerialSLCANInterface(...))` works end to end.
+
+## 8. Add an Optional Manual Browser Smoke Example
+
+Add an example or concise documentation snippet, not a mandatory test.
+
+The example should show:
+
+- The browser app obtains a WebSerial port from JavaScript.
+- The app wraps the port in the async serial protocol adapter.
+- The app creates `CANTransport.new(WebSerialSLCANInterface(...))`.
+
+Do not put real WebSerial/hardware tests into normal `nox`; they are permission-driven and browser-dependent.
+
+## 9. Update Documentation and Public Import Notes
+
+Update `src/pycyphal2/can/__init__.py` documentation to mention the new backend.
+
+Do not eagerly import the backend from `pycyphal2.can.__init__`, matching the existing backend pattern. Users should
+import it explicitly:
+
+```python
+from pycyphal2.can.webserial import WebSerialSLCANInterface
+```
+
+## 10. Verify
+
+Run focused tests first:
+
+```bash
+pytest tests/can/test_slcan.py tests/can/test_webserial.py
+```
+
+Then run the project acceptance command:
+
+```bash
+nox
+```
+
+Per repository policy, the feature is not complete until plain `nox` passes.

From c7d5bf285b2b1813b8601f013dc94e6d8c0094fe Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Mon, 15 Jun 2026 19:14:57 +0300
Subject: [PATCH 03/15] cleanup

---
 .venv/bin/Activate.ps1                        | 247 ------------------
 .venv/bin/activate                            |  70 -----
 .venv/bin/activate-global-python-argcomplete  |   8 -
 .venv/bin/activate.csh                        |  27 --
 .venv/bin/activate.fish                       |  69 -----
 .venv/bin/dependency-groups                   |   8 -
 .venv/bin/httpx                               |   8 -
 .venv/bin/idna                                |   8 -
 .venv/bin/lint-dependency-groups              |   8 -
 .venv/bin/nox                                 |   8 -
 .venv/bin/pbs-install                         |   8 -
 .venv/bin/pip                                 |   8 -
 .venv/bin/pip-install-dependency-groups       |   8 -
 .venv/bin/pip3                                |   8 -
 .venv/bin/pip3.12                             |   8 -
 .venv/bin/pyproject-build                     |   8 -
 .venv/bin/python                              |   1 -
 ...thon-argcomplete-check-easy-install-script |   8 -
 .venv/bin/python3                             |   1 -
 .venv/bin/python3.12                          |   1 -
 .venv/bin/register-python-argcomplete         |   8 -
 .venv/bin/tox-to-nox                          |   8 -
 .venv/bin/virtualenv                          |   8 -
 .venv/lib64                                   |   1 -
 .venv/pyvenv.cfg                              |   5 -
 src/pycyphal2/__init__.py                     |  14 +-
 src/pycyphal2/can/_slcan.py                   |   9 +-
 tests/can/test_slcan.py                       |  10 +
 tests/can/test_webserial.py                   |  14 +
 topic-naming.md                               |   0
 webserial_browser_smoke.md                    | 201 --------------
 webserial_slcan_plan.md                       | 186 -------------
 32 files changed, 41 insertions(+), 943 deletions(-)
 delete mode 100644 .venv/bin/Activate.ps1
 delete mode 100644 .venv/bin/activate
 delete mode 100755 .venv/bin/activate-global-python-argcomplete
 delete mode 100644 .venv/bin/activate.csh
 delete mode 100644 .venv/bin/activate.fish
 delete mode 100755 .venv/bin/dependency-groups
 delete mode 100755 .venv/bin/httpx
 delete mode 100755 .venv/bin/idna
 delete mode 100755 .venv/bin/lint-dependency-groups
 delete mode 100755 .venv/bin/nox
 delete mode 100755 .venv/bin/pbs-install
 delete mode 100755 .venv/bin/pip
 delete mode 100755 .venv/bin/pip-install-dependency-groups
 delete mode 100755 .venv/bin/pip3
 delete mode 100755 .venv/bin/pip3.12
 delete mode 100755 .venv/bin/pyproject-build
 delete mode 120000 .venv/bin/python
 delete mode 100755 .venv/bin/python-argcomplete-check-easy-install-script
 delete mode 120000 .venv/bin/python3
 delete mode 120000 .venv/bin/python3.12
 delete mode 100755 .venv/bin/register-python-argcomplete
 delete mode 100755 .venv/bin/tox-to-nox
 delete mode 100755 .venv/bin/virtualenv
 delete mode 120000 .venv/lib64
 delete mode 100644 .venv/pyvenv.cfg
 delete mode 100644 topic-naming.md
 delete mode 100644 webserial_browser_smoke.md
 delete mode 100644 webserial_slcan_plan.md

diff --git a/.venv/bin/Activate.ps1 b/.venv/bin/Activate.ps1
deleted file mode 100644
index b49d77ba..00000000
--- a/.venv/bin/Activate.ps1
+++ /dev/null
@@ -1,247 +0,0 @@
-<#
-.Synopsis
-Activate a Python virtual environment for the current PowerShell session.
-
-.Description
-Pushes the python executable for a virtual environment to the front of the
-$Env:PATH environment variable and sets the prompt to signify that you are
-in a Python virtual environment. Makes use of the command line switches as
-well as the `pyvenv.cfg` file values present in the virtual environment.
-
-.Parameter VenvDir
-Path to the directory that contains the virtual environment to activate. The
-default value for this is the parent of the directory that the Activate.ps1
-script is located within.
-
-.Parameter Prompt
-The prompt prefix to display when this virtual environment is activated. By
-default, this prompt is the name of the virtual environment folder (VenvDir)
-surrounded by parentheses and followed by a single space (ie. '(.venv) ').
-
-.Example
-Activate.ps1
-Activates the Python virtual environment that contains the Activate.ps1 script.
-
-.Example
-Activate.ps1 -Verbose
-Activates the Python virtual environment that contains the Activate.ps1 script,
-and shows extra information about the activation as it executes.
-
-.Example
-Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
-Activates the Python virtual environment located in the specified location.
-
-.Example
-Activate.ps1 -Prompt "MyPython"
-Activates the Python virtual environment that contains the Activate.ps1 script,
-and prefixes the current prompt with the specified string (surrounded in
-parentheses) while the virtual environment is active.
-
-.Notes
-On Windows, it may be required to enable this Activate.ps1 script by setting the
-execution policy for the user. You can do this by issuing the following PowerShell
-command:
-
-PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
-
-For more information on Execution Policies: 
-https://go.microsoft.com/fwlink/?LinkID=135170
-
-#>
-Param(
-    [Parameter(Mandatory = $false)]
-    [String]
-    $VenvDir,
-    [Parameter(Mandatory = $false)]
-    [String]
-    $Prompt
-)
-
-<# Function declarations --------------------------------------------------- #>
-
-<#
-.Synopsis
-Remove all shell session elements added by the Activate script, including the
-addition of the virtual environment's Python executable from the beginning of
-the PATH variable.
-
-.Parameter NonDestructive
-If present, do not remove this function from the global namespace for the
-session.
-
-#>
-function global:deactivate ([switch]$NonDestructive) {
-    # Revert to original values
-
-    # The prior prompt:
-    if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
-        Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
-        Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
-    }
-
-    # The prior PYTHONHOME:
-    if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
-        Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
-        Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
-    }
-
-    # The prior PATH:
-    if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
-        Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
-        Remove-Item -Path Env:_OLD_VIRTUAL_PATH
-    }
-
-    # Just remove the VIRTUAL_ENV altogether:
-    if (Test-Path -Path Env:VIRTUAL_ENV) {
-        Remove-Item -Path env:VIRTUAL_ENV
-    }
-
-    # Just remove VIRTUAL_ENV_PROMPT altogether.
-    if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
-        Remove-Item -Path env:VIRTUAL_ENV_PROMPT
-    }
-
-    # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
-    if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
-        Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
-    }
-
-    # Leave deactivate function in the global namespace if requested:
-    if (-not $NonDestructive) {
-        Remove-Item -Path function:deactivate
-    }
-}
-
-<#
-.Description
-Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
-given folder, and returns them in a map.
-
-For each line in the pyvenv.cfg file, if that line can be parsed into exactly
-two strings separated by `=` (with any amount of whitespace surrounding the =)
-then it is considered a `key = value` line. The left hand string is the key,
-the right hand is the value.
-
-If the value starts with a `'` or a `"` then the first and last character is
-stripped from the value before being captured.
-
-.Parameter ConfigDir
-Path to the directory that contains the `pyvenv.cfg` file.
-#>
-function Get-PyVenvConfig(
-    [String]
-    $ConfigDir
-) {
-    Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
-
-    # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
-    $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
-
-    # An empty map will be returned if no config file is found.
-    $pyvenvConfig = @{ }
-
-    if ($pyvenvConfigPath) {
-
-        Write-Verbose "File exists, parse `key = value` lines"
-        $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
-
-        $pyvenvConfigContent | ForEach-Object {
-            $keyval = $PSItem -split "\s*=\s*", 2
-            if ($keyval[0] -and $keyval[1]) {
-                $val = $keyval[1]
-
-                # Remove extraneous quotations around a string value.
-                if ("'""".Contains($val.Substring(0, 1))) {
-                    $val = $val.Substring(1, $val.Length - 2)
-                }
-
-                $pyvenvConfig[$keyval[0]] = $val
-                Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
-            }
-        }
-    }
-    return $pyvenvConfig
-}
-
-
-<# Begin Activate script --------------------------------------------------- #>
-
-# Determine the containing directory of this script
-$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
-$VenvExecDir = Get-Item -Path $VenvExecPath
-
-Write-Verbose "Activation script is located in path: '$VenvExecPath'"
-Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
-Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
-
-# Set values required in priority: CmdLine, ConfigFile, Default
-# First, get the location of the virtual environment, it might not be
-# VenvExecDir if specified on the command line.
-if ($VenvDir) {
-    Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
-}
-else {
-    Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
-    $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
-    Write-Verbose "VenvDir=$VenvDir"
-}
-
-# Next, read the `pyvenv.cfg` file to determine any required value such
-# as `prompt`.
-$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
-
-# Next, set the prompt from the command line, or the config file, or
-# just use the name of the virtual environment folder.
-if ($Prompt) {
-    Write-Verbose "Prompt specified as argument, using '$Prompt'"
-}
-else {
-    Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
-    if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
-        Write-Verbose "  Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
-        $Prompt = $pyvenvCfg['prompt'];
-    }
-    else {
-        Write-Verbose "  Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
-        Write-Verbose "  Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
-        $Prompt = Split-Path -Path $venvDir -Leaf
-    }
-}
-
-Write-Verbose "Prompt = '$Prompt'"
-Write-Verbose "VenvDir='$VenvDir'"
-
-# Deactivate any currently active virtual environment, but leave the
-# deactivate function in place.
-deactivate -nondestructive
-
-# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
-# that there is an activated venv.
-$env:VIRTUAL_ENV = $VenvDir
-
-if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
-
-    Write-Verbose "Setting prompt to '$Prompt'"
-
-    # Set the prompt to include the env name
-    # Make sure _OLD_VIRTUAL_PROMPT is global
-    function global:_OLD_VIRTUAL_PROMPT { "" }
-    Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
-    New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
-
-    function global:prompt {
-        Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
-        _OLD_VIRTUAL_PROMPT
-    }
-    $env:VIRTUAL_ENV_PROMPT = $Prompt
-}
-
-# Clear PYTHONHOME
-if (Test-Path -Path Env:PYTHONHOME) {
-    Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
-    Remove-Item -Path Env:PYTHONHOME
-}
-
-# Add the venv to the PATH
-Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
-$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
diff --git a/.venv/bin/activate b/.venv/bin/activate
deleted file mode 100644
index 13cb34bd..00000000
--- a/.venv/bin/activate
+++ /dev/null
@@ -1,70 +0,0 @@
-# This file must be used with "source bin/activate" *from bash*
-# You cannot run it directly
-
-deactivate () {
-    # reset old environment variables
-    if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
-        PATH="${_OLD_VIRTUAL_PATH:-}"
-        export PATH
-        unset _OLD_VIRTUAL_PATH
-    fi
-    if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
-        PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
-        export PYTHONHOME
-        unset _OLD_VIRTUAL_PYTHONHOME
-    fi
-
-    # Call hash to forget past commands. Without forgetting
-    # past commands the $PATH changes we made may not be respected
-    hash -r 2> /dev/null
-
-    if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
-        PS1="${_OLD_VIRTUAL_PS1:-}"
-        export PS1
-        unset _OLD_VIRTUAL_PS1
-    fi
-
-    unset VIRTUAL_ENV
-    unset VIRTUAL_ENV_PROMPT
-    if [ ! "${1:-}" = "nondestructive" ] ; then
-    # Self destruct!
-        unset -f deactivate
-    fi
-}
-
-# unset irrelevant variables
-deactivate nondestructive
-
-# on Windows, a path can contain colons and backslashes and has to be converted:
-if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
-    # transform D:\path\to\venv to /d/path/to/venv on MSYS
-    # and to /cygdrive/d/path/to/venv on Cygwin
-    export VIRTUAL_ENV=$(cygpath /home/maksim/pycyphal/.venv)
-else
-    # use the path as-is
-    export VIRTUAL_ENV=/home/maksim/pycyphal/.venv
-fi
-
-_OLD_VIRTUAL_PATH="$PATH"
-PATH="$VIRTUAL_ENV/"bin":$PATH"
-export PATH
-
-# unset PYTHONHOME if set
-# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
-# could use `if (set -u; : $PYTHONHOME) ;` in bash
-if [ -n "${PYTHONHOME:-}" ] ; then
-    _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
-    unset PYTHONHOME
-fi
-
-if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
-    _OLD_VIRTUAL_PS1="${PS1:-}"
-    PS1='(.venv) '"${PS1:-}"
-    export PS1
-    VIRTUAL_ENV_PROMPT='(.venv) '
-    export VIRTUAL_ENV_PROMPT
-fi
-
-# Call hash to forget past commands. Without forgetting
-# past commands the $PATH changes we made may not be respected
-hash -r 2> /dev/null
diff --git a/.venv/bin/activate-global-python-argcomplete b/.venv/bin/activate-global-python-argcomplete
deleted file mode 100755
index cb817c0f..00000000
--- a/.venv/bin/activate-global-python-argcomplete
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from argcomplete.scripts.activate_global_python_argcomplete import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/activate.csh b/.venv/bin/activate.csh
deleted file mode 100644
index 57a7dc63..00000000
--- a/.venv/bin/activate.csh
+++ /dev/null
@@ -1,27 +0,0 @@
-# This file must be used with "source bin/activate.csh" *from csh*.
-# You cannot run it directly.
-
-# Created by Davide Di Blasi .
-# Ported to Python 3.3 venv by Andrew Svetlov 
-
-alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
-
-# Unset irrelevant variables.
-deactivate nondestructive
-
-setenv VIRTUAL_ENV /home/maksim/pycyphal/.venv
-
-set _OLD_VIRTUAL_PATH="$PATH"
-setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
-
-
-set _OLD_VIRTUAL_PROMPT="$prompt"
-
-if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
-    set prompt = '(.venv) '"$prompt"
-    setenv VIRTUAL_ENV_PROMPT '(.venv) '
-endif
-
-alias pydoc python -m pydoc
-
-rehash
diff --git a/.venv/bin/activate.fish b/.venv/bin/activate.fish
deleted file mode 100644
index d27a3487..00000000
--- a/.venv/bin/activate.fish
+++ /dev/null
@@ -1,69 +0,0 @@
-# This file must be used with "source /bin/activate.fish" *from fish*
-# (https://fishshell.com/). You cannot run it directly.
-
-function deactivate  -d "Exit virtual environment and return to normal shell environment"
-    # reset old environment variables
-    if test -n "$_OLD_VIRTUAL_PATH"
-        set -gx PATH $_OLD_VIRTUAL_PATH
-        set -e _OLD_VIRTUAL_PATH
-    end
-    if test -n "$_OLD_VIRTUAL_PYTHONHOME"
-        set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
-        set -e _OLD_VIRTUAL_PYTHONHOME
-    end
-
-    if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
-        set -e _OLD_FISH_PROMPT_OVERRIDE
-        # prevents error when using nested fish instances (Issue #93858)
-        if functions -q _old_fish_prompt
-            functions -e fish_prompt
-            functions -c _old_fish_prompt fish_prompt
-            functions -e _old_fish_prompt
-        end
-    end
-
-    set -e VIRTUAL_ENV
-    set -e VIRTUAL_ENV_PROMPT
-    if test "$argv[1]" != "nondestructive"
-        # Self-destruct!
-        functions -e deactivate
-    end
-end
-
-# Unset irrelevant variables.
-deactivate nondestructive
-
-set -gx VIRTUAL_ENV /home/maksim/pycyphal/.venv
-
-set -gx _OLD_VIRTUAL_PATH $PATH
-set -gx PATH "$VIRTUAL_ENV/"bin $PATH
-
-# Unset PYTHONHOME if set.
-if set -q PYTHONHOME
-    set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
-    set -e PYTHONHOME
-end
-
-if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
-    # fish uses a function instead of an env var to generate the prompt.
-
-    # Save the current fish_prompt function as the function _old_fish_prompt.
-    functions -c fish_prompt _old_fish_prompt
-
-    # With the original prompt function renamed, we can override with our own.
-    function fish_prompt
-        # Save the return status of the last command.
-        set -l old_status $status
-
-        # Output the venv prompt; color taken from the blue of the Python logo.
-        printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal)
-
-        # Restore the return status of the previous command.
-        echo "exit $old_status" | .
-        # Output the original/"old" prompt.
-        _old_fish_prompt
-    end
-
-    set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
-    set -gx VIRTUAL_ENV_PROMPT '(.venv) '
-end
diff --git a/.venv/bin/dependency-groups b/.venv/bin/dependency-groups
deleted file mode 100755
index c8863302..00000000
--- a/.venv/bin/dependency-groups
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from dependency_groups.__main__ import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/httpx b/.venv/bin/httpx
deleted file mode 100755
index 7d0e6ef4..00000000
--- a/.venv/bin/httpx
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from httpx import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/idna b/.venv/bin/idna
deleted file mode 100755
index d78f8d5f..00000000
--- a/.venv/bin/idna
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from idna.cli import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/lint-dependency-groups b/.venv/bin/lint-dependency-groups
deleted file mode 100755
index faa7c0b2..00000000
--- a/.venv/bin/lint-dependency-groups
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from dependency_groups._lint_dependency_groups import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/nox b/.venv/bin/nox
deleted file mode 100755
index 140ce678..00000000
--- a/.venv/bin/nox
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from nox.__main__ import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pbs-install b/.venv/bin/pbs-install
deleted file mode 100755
index c586872a..00000000
--- a/.venv/bin/pbs-install
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from pbs_installer.__main__ import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pip b/.venv/bin/pip
deleted file mode 100755
index 1ad67de0..00000000
--- a/.venv/bin/pip
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from pip._internal.cli.main import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pip-install-dependency-groups b/.venv/bin/pip-install-dependency-groups
deleted file mode 100755
index c26892e7..00000000
--- a/.venv/bin/pip-install-dependency-groups
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from dependency_groups._pip_wrapper import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pip3 b/.venv/bin/pip3
deleted file mode 100755
index 1ad67de0..00000000
--- a/.venv/bin/pip3
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from pip._internal.cli.main import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pip3.12 b/.venv/bin/pip3.12
deleted file mode 100755
index 1ad67de0..00000000
--- a/.venv/bin/pip3.12
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from pip._internal.cli.main import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/pyproject-build b/.venv/bin/pyproject-build
deleted file mode 100755
index 7046ff63..00000000
--- a/.venv/bin/pyproject-build
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from build.__main__ import entrypoint
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(entrypoint())
diff --git a/.venv/bin/python b/.venv/bin/python
deleted file mode 120000
index b8a0adbb..00000000
--- a/.venv/bin/python
+++ /dev/null
@@ -1 +0,0 @@
-python3
\ No newline at end of file
diff --git a/.venv/bin/python-argcomplete-check-easy-install-script b/.venv/bin/python-argcomplete-check-easy-install-script
deleted file mode 100755
index 52d6513d..00000000
--- a/.venv/bin/python-argcomplete-check-easy-install-script
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from argcomplete.scripts.python_argcomplete_check_easy_install_script import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/python3 b/.venv/bin/python3
deleted file mode 120000
index ae65fdaa..00000000
--- a/.venv/bin/python3
+++ /dev/null
@@ -1 +0,0 @@
-/usr/bin/python3
\ No newline at end of file
diff --git a/.venv/bin/python3.12 b/.venv/bin/python3.12
deleted file mode 120000
index b8a0adbb..00000000
--- a/.venv/bin/python3.12
+++ /dev/null
@@ -1 +0,0 @@
-python3
\ No newline at end of file
diff --git a/.venv/bin/register-python-argcomplete b/.venv/bin/register-python-argcomplete
deleted file mode 100755
index 34dbc6fd..00000000
--- a/.venv/bin/register-python-argcomplete
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from argcomplete.scripts.register_python_argcomplete import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/tox-to-nox b/.venv/bin/tox-to-nox
deleted file mode 100755
index 5607f615..00000000
--- a/.venv/bin/tox-to-nox
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from nox.tox_to_nox import main
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(main())
diff --git a/.venv/bin/virtualenv b/.venv/bin/virtualenv
deleted file mode 100755
index 2e2cba7c..00000000
--- a/.venv/bin/virtualenv
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/home/maksim/pycyphal/.venv/bin/python3
-# -*- coding: utf-8 -*-
-import re
-import sys
-from virtualenv.__main__ import run_with_catch
-if __name__ == '__main__':
-    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
-    sys.exit(run_with_catch())
diff --git a/.venv/lib64 b/.venv/lib64
deleted file mode 120000
index 7951405f..00000000
--- a/.venv/lib64
+++ /dev/null
@@ -1 +0,0 @@
-lib
\ No newline at end of file
diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg
deleted file mode 100644
index a0d48148..00000000
--- a/.venv/pyvenv.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-home = /usr/bin
-include-system-site-packages = false
-version = 3.12.3
-executable = /usr/bin/python3.12
-command = /usr/bin/python3 -m venv /home/maksim/pycyphal/.venv
diff --git a/src/pycyphal2/__init__.py b/src/pycyphal2/__init__.py
index 88043d9b..b268e7dc 100644
--- a/src/pycyphal2/__init__.py
+++ b/src/pycyphal2/__init__.py
@@ -23,19 +23,25 @@
 from pycyphal2.udp import UDPTransport
 
 async def main():
-    node = Node.new(UDPTransport.new(), "my_node")
+    publisher = Node.new(UDPTransport.new_loopback(), "publisher")
+    subscriber = Node.new(UDPTransport.new_loopback(), "subscriber")
 
-    pub = node.advertise("sensor/temperature")
-    await pub(Instant.now() + 1.0, b"21.5")
+    sub = subscriber.subscribe("sensor/temperature")
+    pub = publisher.advertise("sensor/temperature")
 
-    sub = node.subscribe("sensor/temperature")
+    await pub(Instant.now() + 1.0, b"21.5")
     async for arrival in sub:
         print(arrival.message)
+        break
 
 if __name__ == "__main__":
     asyncio.run(main())
 ```
 
+The subscriber is created before publishing so the message is not missed.
+Use distinct nodes/transports for publishing and subscribing; transports generally do not deliver a node's own
+transmissions back to itself.
+
 All public symbols live at the top level — just `import pycyphal2`.
 Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
 so that only the needed dependencies are pulled in.
diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index f2381b1f..2e9fb057 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -15,6 +15,7 @@
 _LF = 0x0A
 _BEL = 0x07
 _MAX_LINE_LENGTH = 256
+_TIMESTAMP_LENGTH = 4
 
 
 def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes:
@@ -95,10 +96,14 @@ def _parse_classic_extended(line: bytes) -> Frame | None:
         _logger.debug("SLCAN drop malformed classic header line=%r", line)
         return None
     expected = 10 + dlc * 2
-    if len(line) != expected:
+    if len(line) == expected + _TIMESTAMP_LENGTH:
+        if not _is_hex(line[expected:]):
+            _logger.debug("SLCAN drop malformed timestamp line=%r", line)
+            return None
+    elif len(line) != expected:
         _logger.debug("SLCAN drop classic dlc mismatch len=%d expected=%d", len(line), expected)
         return None
-    return _make_frame(identifier, line[10:])
+    return _make_frame(identifier, line[10:expected])
 
 
 def _make_frame(identifier: int, data_hex: bytes) -> Frame | None:
diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py
index e38ebbea..cec322ce 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_slcan.py
@@ -29,6 +29,16 @@ def test_parse_classic_extended_frames() -> None:
     assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xAB")]
 
 
+def test_parse_classic_extended_frames_with_timestamp_suffix() -> None:
+    parser = SLCANParser()
+
+    assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xAB\xCD")]
+    assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [
+        Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xFF\x00\x00\x00\xA0"),
+    ]
+    assert parser.feed(b"T000001232ABCDzzzz\r") == []
+
+
 def test_parse_multiple_frames_and_newlines() -> None:
     parser = SLCANParser()
 
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index 70a9ea48..ab9e31fc 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -105,6 +105,20 @@ async def run() -> None:
     asyncio.run(run())
 
 
+def test_receive_accepts_slcan_timestamp_suffix() -> None:
+    async def run() -> None:
+        port = _FakeAsyncSerial([b"T10AE6EFF8000000FF000000A07071\r"])
+        iface = WebSerialSLCANInterface(port)
+
+        frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
+
+        assert frame.id == 0x10AE6EFF
+        assert frame.data == b"\x00\x00\x00\xFF\x00\x00\x00\xA0"
+        iface.close()
+
+    asyncio.run(run())
+
+
 def test_receive_applies_local_filters() -> None:
     async def run() -> None:
         port = _FakeAsyncSerial([b"T000001231AA\rT000004561BB\r"])
diff --git a/topic-naming.md b/topic-naming.md
deleted file mode 100644
index e69de29b..00000000
diff --git a/webserial_browser_smoke.md b/webserial_browser_smoke.md
deleted file mode 100644
index 8270cd53..00000000
--- a/webserial_browser_smoke.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# WebSerial SLCAN Browser Smoke Test
-
-This is a manual smoke test for `pycyphal2.can.webserial.WebSerialSLCANInterface` in a Pyodide browser runtime.
-It is intentionally not part of `nox` because it depends on a browser permission prompt, WebSerial support, and real
-SLCAN/CAN hardware.
-
-## Preconditions
-
-- Browser with WebSerial support, such as a Chromium-based browser.
-- Secure context. `http://localhost` is acceptable for local development.
-- An SLCAN-compatible serial CAN adapter connected to a real CAN bus.
-- Another CAN node on the bus, or an adapter/firmware mode that echoes transmitted frames.
-- A local `pycyphal2` wheel served over HTTP.
-
-Build and serve the wheel from the repository root:
-
-```bash
-python -m build
-python -m http.server 8000
-```
-
-Open the smoke page from `http://localhost:8000/...` so the wheel and page share the same origin.
-
-## Smoke Page
-
-Use this as the page body. Adjust the Pyodide version, wheel URL, serial baud rate, and SLCAN bitrate command for the
-adapter under test.
-
-```html
-
-
-pycyphal2 WebSerial SLCAN Smoke
-
-
-

-
-
-
-```
-
-## Expected Result
-
-- The browser asks for serial-port permission after the button click.
-- The adapter receives `C`, bitrate, and `O` SLCAN commands.
-- Python creates `WebSerialSLCANInterface` and `CANTransport`.
-- A Cyphal/CAN message is published on subject `1234`.
-- If another CAN node or adapter echo produces a subject `1234` transfer, the arrival is printed in the console.
-
-## Notes
-
-- Replace `./dist/pycyphal2-0.0.0-py3-none-any.whl` with the actual wheel filename in `dist/`.
-- The example configures Classic CAN only; the current SLCAN backend intentionally has no CAN FD support.
-- The interface does not configure SLCAN adapters by itself. The smoke page sends `C`, `S6`, and `O` before constructing
-  `WebSerialSLCANInterface`.
-- If receive times out, first verify that the CAN bus has another active node or that the adapter emits loopback frames.
-
-## References
-
-- MDN Web Serial API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API
-- MDN `SerialPort.open()`: https://developer.mozilla.org/en-US/docs/Web/API/SerialPort/open
-- Pyodide type translations and JS proxy behavior: https://pyodide.org/en/stable/usage/type-conversions.html
diff --git a/webserial_slcan_plan.md b/webserial_slcan_plan.md
deleted file mode 100644
index 09211942..00000000
--- a/webserial_slcan_plan.md
+++ /dev/null
@@ -1,186 +0,0 @@
-# WebSerial SLCAN CAN Interface Plan
-
-This plan outlines how to add and test a CAN interface backend for running `pycyphal2` inside a browser through
-Pyodide, using WebSerial to communicate with an SLCAN-compatible CAN adapter.
-
-## 1. Add a Private SLCAN Codec
-
-Create `src/pycyphal2/can/_slcan.py`.
-
-Keep this module pure Python and dependency-free. It should handle only SLCAN byte/text encoding and decoding, without
-knowing about WebSerial or `asyncio`.
-
-Suggested shape:
-
-```python
-def encode_frame(identifier: int, data: bytes) -> bytes: ...
-
-
-class SLCANParser:
-    def feed(self, chunk: bytes) -> list[Frame]: ...
-```
-
-Handle:
-
-- Classic extended data frames: `T{ID:08X}{DLC}{DATA}\r`.
-- Optional CANDapter extended alias: `x...`.
-- Drop standard frames, remote frames, malformed hex, DLC/data mismatch, and overlength payloads.
-- Bound the internal receive buffer so malformed input cannot grow memory forever.
-
-Do not copy python-can code. Use its SLCAN behavior as a reference only.
-
-## 2. Define a Minimal Async Serial Protocol
-
-Create `src/pycyphal2/can/webserial.py`.
-
-Avoid binding the interface directly to JavaScript globals. Define a small typed protocol that can be implemented by a
-Pyodide WebSerial adapter and by CPython test doubles:
-
-```python
-class AsyncSerialPort(Protocol):
-    async def read(self) -> bytes: ...
-
-    async def write(self, data: bytes) -> None: ...
-
-    async def close(self) -> None: ...
-```
-
-Then implement:
-
-```python
-class WebSerialSLCANInterface(Interface):
-    def __init__(self, port: AsyncSerialPort, *, name: str = "webserial") -> None: ...
-```
-
-This keeps the backend testable under normal CPython and avoids requiring browser objects at import time.
-
-## 3. Implement Interface Semantics
-
-Mirror the general structure of `src/pycyphal2/can/pythoncan.py`, but keep it fully async and thread-free.
-
-Core behavior:
-
-- `name`: return the configured interface name.
-- `fd`: return `False`.
-- `filter(filters)`: store filters locally; SLCAN has no portable acceptance-filter command.
-- `enqueue(id, data, deadline)`: push frames into an `asyncio.PriorityQueue`.
-- `_tx_loop()`: pop queued frames, enforce deadlines, encode SLCAN, and `await port.write(...)`.
-- RX path: read serial chunks, feed the parser, locally apply filters, and enqueue `TimestampedFrame` instances.
-- `purge()`: clear queued TX frames.
-- `close()`: cancel tasks, unblock pending `receive()`, close the port, and remain idempotent.
-- Failures: record the first failure and raise `ClosedError`, following the existing backend pattern.
-
-Use `tests/can/_support.py` as the behavior model for local filter matching.
-
-## 4. Keep Browser Wiring Separate
-
-Browser permission flow should not be embedded into the importable backend.
-
-Add either:
-
-- a small `from_webserial_port(...)` classmethod, or
-- a separate example/helper module.
-
-The application should perform `navigator.serial.requestPort()` from JavaScript or Pyodide-side glue code, then pass an
-already-open async serial adapter into `WebSerialSLCANInterface`.
-
-This matters because WebSerial depends on browser support, secure context, permissions policy, and user activation.
-
-## 5. Unit-Test the Codec
-
-Add `tests/can/test_slcan.py`.
-
-Cover:
-
-- Encode empty, 1-byte, and 8-byte Classic frames.
-- Encode maximum extended ID `0x1FFFFFFF`.
-- Reject payloads larger than 8 bytes.
-- Parse fragmented input, such as `b"T000001"` followed by the rest of the line.
-- Parse multiple frames in one chunk.
-- Drop malformed lines without raising.
-- Drop standard and remote frames.
-- Drop DLC/data mismatch.
-
-These tests should be pure synchronous tests.
-
-## 6. Unit-Test the Interface With Fake Serial
-
-Add `tests/can/test_webserial.py`.
-
-Create a fake async serial port:
-
-```python
-class FakeAsyncSerial:
-    writes: list[bytes]
-
-    async def read(self) -> bytes: ...
-
-    async def write(self, data: bytes) -> None: ...
-
-    async def close(self) -> None: ...
-```
-
-Test:
-
-- `enqueue()` writes expected SLCAN bytes.
-- `receive()` returns `TimestampedFrame`.
-- Filters are applied locally.
-- Expired deadlines are dropped.
-- `purge()` drops pending TX.
-- Malformed RX input is ignored and the next valid frame is received.
-- `close()` is idempotent.
-- Pending `receive()` unblocks on close.
-- Read/write failure becomes `ClosedError`.
-
-## 7. Add Transport-Level Fake Bus Tests
-
-Build a fake SLCAN serial hub connecting two fake serial ports. When one side writes a `T...` frame, the hub feeds that
-line into the other side's read queue.
-
-Add transport tests equivalent to the important SocketCAN smoke tests:
-
-- Pub/sub smoke.
-- Unicast smoke.
-- Self-loopback behavior, if supported/configurable.
-- Node-ID reroll collision case, if the fake hub supports loopback.
-
-This verifies that `CANTransport.new(WebSerialSLCANInterface(...))` works end to end.
-
-## 8. Add an Optional Manual Browser Smoke Example
-
-Add an example or concise documentation snippet, not a mandatory test.
-
-The example should show:
-
-- The browser app obtains a WebSerial port from JavaScript.
-- The app wraps the port in the async serial protocol adapter.
-- The app creates `CANTransport.new(WebSerialSLCANInterface(...))`.
-
-Do not put real WebSerial/hardware tests into normal `nox`; they are permission-driven and browser-dependent.
-
-## 9. Update Documentation and Public Import Notes
-
-Update `src/pycyphal2/can/__init__.py` documentation to mention the new backend.
-
-Do not eagerly import the backend from `pycyphal2.can.__init__`, matching the existing backend pattern. Users should
-import it explicitly:
-
-```python
-from pycyphal2.can.webserial import WebSerialSLCANInterface
-```
-
-## 10. Verify
-
-Run focused tests first:
-
-```bash
-pytest tests/can/test_slcan.py tests/can/test_webserial.py
-```
-
-Then run the project acceptance command:
-
-```bash
-nox
-```
-
-Per repository policy, the feature is not complete until plain `nox` passes.

From d31efcda91c28b832c65fcf453d57dba265ef0ce Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Mon, 15 Jun 2026 19:17:25 +0300
Subject: [PATCH 04/15] restore __init__.py

---
 src/pycyphal2/__init__.py | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/src/pycyphal2/__init__.py b/src/pycyphal2/__init__.py
index b268e7dc..88043d9b 100644
--- a/src/pycyphal2/__init__.py
+++ b/src/pycyphal2/__init__.py
@@ -23,25 +23,19 @@
 from pycyphal2.udp import UDPTransport
 
 async def main():
-    publisher = Node.new(UDPTransport.new_loopback(), "publisher")
-    subscriber = Node.new(UDPTransport.new_loopback(), "subscriber")
-
-    sub = subscriber.subscribe("sensor/temperature")
-    pub = publisher.advertise("sensor/temperature")
+    node = Node.new(UDPTransport.new(), "my_node")
 
+    pub = node.advertise("sensor/temperature")
     await pub(Instant.now() + 1.0, b"21.5")
+
+    sub = node.subscribe("sensor/temperature")
     async for arrival in sub:
         print(arrival.message)
-        break
 
 if __name__ == "__main__":
     asyncio.run(main())
 ```
 
-The subscriber is created before publishing so the message is not missed.
-Use distinct nodes/transports for publishing and subscribing; transports generally do not deliver a node's own
-transmissions back to itself.
-
 All public symbols live at the top level — just `import pycyphal2`.
 Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
 so that only the needed dependencies are pulled in.

From d091fa5c66f14384cb202ca671acf9b02b0b8468 Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Mon, 15 Jun 2026 19:18:15 +0300
Subject: [PATCH 05/15] restore noxfile.py

---
 noxfile.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/noxfile.py b/noxfile.py
index 6ff47ea4..40c6ffcf 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -2,12 +2,11 @@
 
 import shutil
 from pathlib import Path
-
 import nox
 
 nox.options.sessions = ["test", "mypy", "lint", "format"]
 
-PYTHONS = ["3.12"]
+PYTHONS = ["3.11", "3.12", "3.13"]
 
 
 @nox.session(python=False, default=False)

From ad3debec8ceb83842b9f48f78617957031d848de Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Mon, 15 Jun 2026 19:23:17 +0300
Subject: [PATCH 06/15] format

---
 .gitignore                     |  1 +
 noxfile.py                     |  1 +
 src/pycyphal2/can/webserial.py |  4 +---
 tests/can/test_slcan.py        | 12 ++++++------
 tests/can/test_webserial.py    | 18 +++++++++---------
 5 files changed, 18 insertions(+), 18 deletions(-)

diff --git a/.gitignore b/.gitignore
index f7703234..75171247 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ coverage.xml
 *,cover
 .*mypy.json
 .python-version
+.venv/
 
 .project
 .pydevproject
diff --git a/noxfile.py b/noxfile.py
index 40c6ffcf..3adb2326 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -2,6 +2,7 @@
 
 import shutil
 from pathlib import Path
+
 import nox
 
 nox.options.sessions = ["test", "mypy", "lint", "format"]
diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py
index d37debad..9b64ecd8 100644
--- a/src/pycyphal2/can/webserial.py
+++ b/src/pycyphal2/can/webserial.py
@@ -152,9 +152,7 @@ async def _rx_loop(self) -> None:
                 return
             for frame in self._parser.feed(chunk):
                 if match_filters(self._filters, frame.id):
-                    self._rx_queue.put_nowait(
-                        TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now())
-                    )
+                    self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
 
     def _fail(self, ex: BaseException) -> None:
         if self._failure is None:
diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py
index cec322ce..0623d34d 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_slcan.py
@@ -8,7 +8,7 @@
 
 def test_encode_classic_extended_frames() -> None:
     assert encode_frame(0x123, b"") == b"T000001230\r"
-    assert encode_frame(0x1BADC0DE, b"\x01\xAB") == b"T1BADC0DE201AB\r"
+    assert encode_frame(0x1BADC0DE, b"\x01\xab") == b"T1BADC0DE201AB\r"
     assert encode_frame(0x1FFFFFFF, bytes(range(8))) == b"T1FFFFFFF80001020304050607\r"
 
 
@@ -23,18 +23,18 @@ def test_encode_validation() -> None:
 def test_parse_classic_extended_frames() -> None:
     parser = SLCANParser()
 
-    assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xAB\xCD")]
+    assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T000001") == []
     assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")]
-    assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xAB")]
+    assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xab")]
 
 
 def test_parse_classic_extended_frames_with_timestamp_suffix() -> None:
     parser = SLCANParser()
 
-    assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xAB\xCD")]
+    assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [
-        Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xFF\x00\x00\x00\xA0"),
+        Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xff\x00\x00\x00\xa0"),
     ]
     assert parser.feed(b"T000001232ABCDzzzz\r") == []
 
@@ -44,7 +44,7 @@ def test_parse_multiple_frames_and_newlines() -> None:
 
     assert parser.feed(b"T000000010\rT1FFFFFFF1AA\r\n") == [
         Frame(id=1, data=b""),
-        Frame(id=0x1FFFFFFF, data=b"\xAA"),
+        Frame(id=0x1FFFFFFF, data=b"\xaa"),
     ]
 
 
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index ab9e31fc..3cf031f1 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -78,7 +78,7 @@ def test_enqueue_writes_expected_slcan_bytes() -> None:
     async def run() -> None:
         port = _FakeAsyncSerial()
         iface = WebSerialSLCANInterface(port)
-        iface.enqueue(0x123, [memoryview(b"\xAA"), memoryview(b"")], Instant.now() + 1.0)
+        iface.enqueue(0x123, [memoryview(b"\xaa"), memoryview(b"")], Instant.now() + 1.0)
 
         await _wait_for(lambda: len(port.writes) == 2)
         assert port.writes == [b"T000001231AA\r", b"T000001230\r"]
@@ -98,7 +98,7 @@ async def run() -> None:
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
         after = Instant.now()
 
-        assert frame == TimestampedFrame(id=0x123, data=b"\xAA", timestamp=frame.timestamp)
+        assert frame == TimestampedFrame(id=0x123, data=b"\xaa", timestamp=frame.timestamp)
         assert before.ns <= frame.timestamp.ns <= after.ns
         iface.close()
 
@@ -113,7 +113,7 @@ async def run() -> None:
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
 
         assert frame.id == 0x10AE6EFF
-        assert frame.data == b"\x00\x00\x00\xFF\x00\x00\x00\xA0"
+        assert frame.data == b"\x00\x00\x00\xff\x00\x00\x00\xa0"
         iface.close()
 
     asyncio.run(run())
@@ -128,7 +128,7 @@ async def run() -> None:
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
 
         assert frame.id == 0x456
-        assert frame.data == b"\xBB"
+        assert frame.data == b"\xbb"
         iface.close()
 
     asyncio.run(run())
@@ -139,8 +139,8 @@ async def run() -> None:
         port = _FakeAsyncSerial()
         iface = WebSerialSLCANInterface(port)
 
-        iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + (-1.0))
-        iface.enqueue(0x124, [memoryview(b"\xBB")], Instant.now() + 1.0)
+        iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + (-1.0))
+        iface.enqueue(0x124, [memoryview(b"\xbb")], Instant.now() + 1.0)
 
         await _wait_for(lambda: len(port.writes) == 1)
         assert port.writes == [b"T000001241BB\r"]
@@ -154,9 +154,9 @@ async def run() -> None:
         port = _FakeAsyncSerial()
         iface = WebSerialSLCANInterface(port)
 
-        iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + 10.0)
+        iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 10.0)
         iface.purge()
-        iface.enqueue(0x124, [memoryview(b"\xBB")], Instant.now() + 1.0)
+        iface.enqueue(0x124, [memoryview(b"\xbb")], Instant.now() + 1.0)
 
         await _wait_for(lambda: len(port.writes) == 1)
         assert port.writes == [b"T000001241BB\r"]
@@ -209,7 +209,7 @@ async def run() -> None:
         port.write_errors.append(OSError("tx failed"))
         iface = WebSerialSLCANInterface(port)
 
-        iface.enqueue(0x123, [memoryview(b"\xAA")], Instant.now() + 1.0)
+        iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 1.0)
 
         await _wait_for(lambda: port.closed)
         with pytest.raises(ClosedError, match="failed") as exc_info:

From cb97d3619882ead2bd6daf5bb35ed5db5450c46f Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Mon, 15 Jun 2026 19:40:05 +0300
Subject: [PATCH 07/15] Fix importorskip line

---
 tests/can/test_socketcan.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/can/test_socketcan.py b/tests/can/test_socketcan.py
index ff433942..71d30458 100644
--- a/tests/can/test_socketcan.py
+++ b/tests/can/test_socketcan.py
@@ -1,8 +1,8 @@
 from __future__ import annotations
 
 import asyncio
-from pathlib import Path
 import sys
+from pathlib import Path
 
 import pytest
 
@@ -12,7 +12,7 @@
 from pycyphal2.can._wire import HEARTBEAT_SUBJECT_ID, TransferKind, serialize_transfer
 from tests.can._support import wait_for
 
-socketcan = pytest.importorskip("pycyphal2.can.socketcan", reason="SocketCAN backend unavailable")
+socketcan = pytest.importorskip("pycyphal2.can.socketcan", reason="SocketCAN backend unavailable", exc_type=ImportError)
 SocketCANInterface = socketcan.SocketCANInterface
 list_interfaces = SocketCANInterface.list_interfaces
 

From f6f7806642210f7765bad7431dc5da847d5b93f1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Jun 2026 11:03:24 +0000
Subject: [PATCH 08/15] Address PR review feedback for WebSerial and SLCAN

---
 src/pycyphal2/can/_slcan.py    | 67 ++++++++++++++++++++++++++--------
 src/pycyphal2/can/webserial.py | 25 +++++--------
 tests/can/test_slcan.py        |  9 ++++-
 tests/can/test_webserial.py    | 11 +++---
 4 files changed, 74 insertions(+), 38 deletions(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index 2e9fb057..e0a70198 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -9,11 +9,13 @@
 _logger = logging.getLogger(__name__)
 
 _CAN_EXT_ID_MASK = (1 << 29) - 1
+_CAN_STD_ID_MASK = (1 << 11) - 1
 _CAN_CLASSIC_MTU = 8
 _HEX_CHARS = frozenset(b"0123456789abcdefABCDEF")
 _CR = 0x0D
 _LF = 0x0A
 _BEL = 0x07
+_ETX = 0x03
 _MAX_LINE_LENGTH = 256
 _TIMESTAMP_LENGTH = 4
 
@@ -76,34 +78,49 @@ def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
 
 
 def _parse_line(line: bytes) -> Frame | None:
+    line = line.strip().strip(bytes([_BEL])).strip(bytes([_ETX]))
+    if not line:
+        return None
     command = line[:1]
     if command in (b"T", b"x"):
-        return _parse_classic_extended(line)
-    if command in (b"t", b"r", b"R"):
+        return _parse_data_frame(line, id_length=8, max_payload_length=_CAN_CLASSIC_MTU)
+    if command == b"t":
+        return _parse_data_frame(line, id_length=3, max_payload_length=_CAN_CLASSIC_MTU)
+    if command == b"D":
+        return _parse_data_frame(line, id_length=8, max_payload_length=64)
+    if command in (b"r", b"R"):
         _logger.debug("SLCAN drop unsupported frame type cmd=%r", command)
         return None
     _logger.debug("SLCAN drop unknown line=%r", line)
     return None
 
 
-def _parse_classic_extended(line: bytes) -> Frame | None:
-    if len(line) < 10:
-        _logger.debug("SLCAN drop short classic line=%r", line)
+def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -> Frame | None:
+    header_length = 2 + id_length
+    if len(line) < header_length:
+        _logger.debug("SLCAN drop short data line=%r", line)
         return None
-    identifier = _parse_hex_int(line[1:9])
-    dlc = _parse_classic_dlc(line[9])
+    identifier = _parse_hex_int(line[1 : 1 + id_length])
+    dlc = _parse_dlc(line[1 + id_length])
     if identifier is None or dlc is None:
-        _logger.debug("SLCAN drop malformed classic header line=%r", line)
+        _logger.debug("SLCAN drop malformed data header line=%r", line)
+        return None
+    payload_length = _dlc_to_length(dlc)
+    if payload_length > max_payload_length:
+        _logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
         return None
-    expected = 10 + dlc * 2
-    if len(line) == expected + _TIMESTAMP_LENGTH:
-        if not _is_hex(line[expected:]):
+    expected = header_length + payload_length * 2
+    if len(line) >= expected + _TIMESTAMP_LENGTH:
+        if not _is_hex(line[-_TIMESTAMP_LENGTH:]):
             _logger.debug("SLCAN drop malformed timestamp line=%r", line)
             return None
     elif len(line) != expected:
-        _logger.debug("SLCAN drop classic dlc mismatch len=%d expected=%d", len(line), expected)
+        _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
+        return None
+    if id_length == 3 and identifier > _CAN_STD_ID_MASK:
+        _logger.debug("SLCAN drop invalid standard id=%03x", identifier)
         return None
-    return _make_frame(identifier, line[10:expected])
+    return _make_frame(identifier, line[header_length:expected])
 
 
 def _make_frame(identifier: int, data_hex: bytes) -> Frame | None:
@@ -130,8 +147,28 @@ def _parse_hex_bytes(value: bytes) -> bytes | None:
     return bytes.fromhex(value.decode("ascii"))
 
 
-def _parse_classic_dlc(value: int) -> int | None:
-    return value - ord("0") if ord("0") <= value <= ord("8") else None
+def _parse_dlc(value: int) -> int | None:
+    return int(chr(value), 16) if value in _HEX_CHARS else None
+
+
+def _dlc_to_length(dlc: int) -> int:
+    if 0 <= dlc <= 8:
+        return dlc
+    if dlc == 9:
+        return 12
+    if dlc == 10:
+        return 16
+    if dlc == 11:
+        return 20
+    if dlc == 12:
+        return 24
+    if dlc == 13:
+        return 32
+    if dlc == 14:
+        return 48
+    if dlc == 15:
+        return 64
+    return 0
 
 
 def _is_hex(value: bytes) -> bool:
diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py
index 9b64ecd8..ceac9c44 100644
--- a/src/pycyphal2/can/webserial.py
+++ b/src/pycyphal2/can/webserial.py
@@ -3,35 +3,31 @@
 from __future__ import annotations
 
 import asyncio
+from abc import ABC, abstractmethod
 from collections.abc import Iterable
 import logging
-from typing import Protocol, runtime_checkable
 
 from .._api import ClosedError, Instant
 from ._interface import Filter, Interface, TimestampedFrame
 from ._slcan import SLCANParser, encode_frame
-from ._wire import match_filters
 
 _logger = logging.getLogger(__name__)
 
 
-@runtime_checkable
-class AsyncSerialPort(Protocol):
+class AsyncSerialPort(ABC):
     """Minimal async byte stream expected from a WebSerial adapter."""
 
+    @abstractmethod
     async def read(self) -> bytes:
-        """Return the next received byte chunk. An empty chunk indicates end-of-stream."""
-        del self
         raise NotImplementedError
 
+    @abstractmethod
     async def write(self, data: bytes) -> None:
-        """Write one byte chunk to the serial adapter."""
-        del self, data
+        del data
         raise NotImplementedError
 
+    @abstractmethod
     async def close(self) -> None:
-        """Close the serial adapter."""
-        del self
         raise NotImplementedError
 
 
@@ -48,7 +44,6 @@ def __init__(self, port: AsyncSerialPort, *, name: str = "webserial") -> None:
         self._name = str(name)
         self._closed = False
         self._failure: BaseException | None = None
-        self._filters = [Filter.promiscuous()]
         self._parser = SLCANParser()
         self._tx_seq = 0
         self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
@@ -67,9 +62,8 @@ def fd(self) -> bool:
         return False
 
     def filter(self, filters: Iterable[Filter]) -> None:
+        del filters
         self._raise_if_closed()
-        self._filters = list(filters)
-        _logger.debug("WebSerial SLCAN filters set iface=%s n=%d", self._name, len(self._filters))
 
     def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
         self._raise_if_closed()
@@ -110,7 +104,7 @@ def close(self) -> None:
         self._close(ClosedError(f"WebSerial SLCAN interface {self._name} closed"))
 
     def __repr__(self) -> str:
-        return f"{type(self).__name__}({self._name!r}, fd=False)"
+        return f"{type(self).__name__}({self._name!r}, fd={self.fd})"
 
     async def _tx_loop(self) -> None:
         while not self._closed:
@@ -151,8 +145,7 @@ async def _rx_loop(self) -> None:
                 self._fail(EOFError(f"WebSerial SLCAN interface {self._name} ended"))
                 return
             for frame in self._parser.feed(chunk):
-                if match_filters(self._filters, frame.id):
-                    self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
+                self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
 
     def _fail(self, ex: BaseException) -> None:
         if self._failure is None:
diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py
index 0623d34d..3609280f 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_slcan.py
@@ -24,6 +24,7 @@ def test_parse_classic_extended_frames() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"t1231AA\r") == [Frame(id=0x123, data=b"\xaa")]
     assert parser.feed(b"T000001") == []
     assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")]
     assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xab")]
@@ -33,6 +34,8 @@ def test_parse_classic_extended_frames_with_timestamp_suffix() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232ABCDxxxx1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232ABCD1234\x03\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [
         Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xff\x00\x00\x00\xa0"),
     ]
@@ -48,10 +51,11 @@ def test_parse_multiple_frames_and_newlines() -> None:
     ]
 
 
-def test_parse_drops_unsupported_frame_types() -> None:
+def test_parse_fd_and_drops_unsupported_frame_types() -> None:
     parser = SLCANParser()
 
-    assert parser.feed(b"t1231AA\rr1231\rR000001231\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")]
+    assert parser.feed(b"D000001239000102030405060708090A0B\r") == [Frame(id=0x123, data=bytes(range(12)))]
+    assert parser.feed(b"r1231\rR000001231\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")]
 
 
 def test_parse_drops_malformed_input_without_raising() -> None:
@@ -62,6 +66,7 @@ def test_parse_drops_malformed_input_without_raising() -> None:
     assert parser.feed(b"T000001232AABBCC\r") == []
     assert parser.feed(b"T000001232AAGG\r") == []
     assert parser.feed(b"TFFFFFFFF0\r") == []
+    assert parser.feed(b"tABC0\r") == []
     assert parser.feed(b"V0102\rN1234\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")]
 
 
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index 3cf031f1..1e97e6c4 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import asyncio
+from abc import ABC
 from collections.abc import Callable
 
 import pytest
@@ -57,8 +58,8 @@ async def _wait_for(predicate: Callable[[], bool], timeout: float = 1.0) -> None
     raise AssertionError("predicate did not become true within timeout")
 
 
-def test_async_serial_port_protocol_runtime_check() -> None:
-    assert isinstance(_FakeAsyncSerial(), AsyncSerialPort)
+def test_async_serial_port_is_abc() -> None:
+    assert issubclass(AsyncSerialPort, ABC)
 
 
 def test_webserial_interface_properties_and_sync_close() -> None:
@@ -119,7 +120,7 @@ async def run() -> None:
     asyncio.run(run())
 
 
-def test_receive_applies_local_filters() -> None:
+def test_receive_filter_is_noop() -> None:
     async def run() -> None:
         port = _FakeAsyncSerial([b"T000001231AA\rT000004561BB\r"])
         iface = WebSerialSLCANInterface(port)
@@ -127,8 +128,8 @@ async def run() -> None:
 
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
 
-        assert frame.id == 0x456
-        assert frame.data == b"\xbb"
+        assert frame.id == 0x123
+        assert frame.data == b"\xaa"
         iface.close()
 
     asyncio.run(run())

From afdac2088fcc4146620d91cca6b0e4b9701800ff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Jun 2026 11:08:48 +0000
Subject: [PATCH 09/15] Refine SLCAN parity and WebSerial interface behavior

---
 src/pycyphal2/can/_slcan.py    | 44 ++++++++++++++++------------------
 src/pycyphal2/can/webserial.py |  2 +-
 tests/can/test_slcan.py        |  2 ++
 3 files changed, 23 insertions(+), 25 deletions(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index e0a70198..503e85b9 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -15,9 +15,10 @@
 _CR = 0x0D
 _LF = 0x0A
 _BEL = 0x07
-_ETX = 0x03
 _MAX_LINE_LENGTH = 256
 _TIMESTAMP_LENGTH = 4
+_DLC_TO_LENGTH = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
+_STRIP_CHARS = b" \t\r\n\x07\x03"
 
 
 def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes:
@@ -78,7 +79,8 @@ def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
 
 
 def _parse_line(line: bytes) -> Frame | None:
-    line = line.strip().strip(bytes([_BEL])).strip(bytes([_ETX]))
+    # REFERENCE PARITY: pydronecan strips surrounding whitespace and control characters like BEL/ETX.
+    line = line.strip(_STRIP_CHARS)
     if not line:
         return None
     command = line[:1]
@@ -106,11 +108,15 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
         _logger.debug("SLCAN drop malformed data header line=%r", line)
         return None
     payload_length = _dlc_to_length(dlc)
+    if payload_length is None:
+        _logger.debug("SLCAN drop malformed dlc=%r line=%r", dlc, line)
+        return None
     if payload_length > max_payload_length:
         _logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
         return None
     expected = header_length + payload_length * 2
     if len(line) >= expected + _TIMESTAMP_LENGTH:
+        # REFERENCE PARITY: accept and ignore extra bytes between payload and timestamp, check tail only.
         if not _is_hex(line[-_TIMESTAMP_LENGTH:]):
             _logger.debug("SLCAN drop malformed timestamp line=%r", line)
             return None
@@ -118,7 +124,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
         _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
         return None
     if id_length == 3 and identifier > _CAN_STD_ID_MASK:
-        _logger.debug("SLCAN drop invalid standard id=%03x", identifier)
+        _logger.debug("SLCAN drop invalid standard id=%x", identifier)
         return None
     return _make_frame(identifier, line[header_length:expected])
 
@@ -148,27 +154,17 @@ def _parse_hex_bytes(value: bytes) -> bytes | None:
 
 
 def _parse_dlc(value: int) -> int | None:
-    return int(chr(value), 16) if value in _HEX_CHARS else None
-
-
-def _dlc_to_length(dlc: int) -> int:
-    if 0 <= dlc <= 8:
-        return dlc
-    if dlc == 9:
-        return 12
-    if dlc == 10:
-        return 16
-    if dlc == 11:
-        return 20
-    if dlc == 12:
-        return 24
-    if dlc == 13:
-        return 32
-    if dlc == 14:
-        return 48
-    if dlc == 15:
-        return 64
-    return 0
+    if ord("0") <= value <= ord("9"):
+        return value - ord("0")
+    if ord("A") <= value <= ord("F"):
+        return 10 + value - ord("A")
+    if ord("a") <= value <= ord("f"):
+        return 10 + value - ord("a")
+    return None
+
+
+def _dlc_to_length(dlc: int) -> int | None:
+    return _DLC_TO_LENGTH[dlc] if 0 <= dlc < len(_DLC_TO_LENGTH) else None
 
 
 def _is_hex(value: bytes) -> bool:
diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py
index ceac9c44..83eb95ac 100644
--- a/src/pycyphal2/can/webserial.py
+++ b/src/pycyphal2/can/webserial.py
@@ -23,7 +23,6 @@ async def read(self) -> bytes:
 
     @abstractmethod
     async def write(self, data: bytes) -> None:
-        del data
         raise NotImplementedError
 
     @abstractmethod
@@ -64,6 +63,7 @@ def fd(self) -> bool:
     def filter(self, filters: Iterable[Filter]) -> None:
         del filters
         self._raise_if_closed()
+        # No-op: WebSerial adapters do not provide hardware acceptance filtering.
 
     def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
         self._raise_if_closed()
diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py
index 3609280f..b824ef39 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_slcan.py
@@ -25,6 +25,7 @@ def test_parse_classic_extended_frames() -> None:
 
     assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"t1231AA\r") == [Frame(id=0x123, data=b"\xaa")]
+    assert parser.feed(b"t7FF1AA\r") == [Frame(id=0x7FF, data=b"\xaa")]
     assert parser.feed(b"T000001") == []
     assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")]
     assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xab")]
@@ -34,6 +35,7 @@ def test_parse_classic_extended_frames_with_timestamp_suffix() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    # REFERENCE PARITY: pydronecan accepts garbage before the 4-hex timestamp tail; keep this permissive behavior.
     assert parser.feed(b"T000001232ABCDxxxx1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T000001232ABCD1234\x03\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [

From ec6c2555c476421b02f672a98e34d160bc21db4b Mon Sep 17 00:00:00 2001
From: Pavel Kirienko 
Date: Tue, 16 Jun 2026 14:20:16 +0300
Subject: [PATCH 10/15] restore __init__.py

---
 src/pycyphal2/can/__init__.py | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/src/pycyphal2/can/__init__.py b/src/pycyphal2/can/__init__.py
index 3990a071..9ff713d2 100644
--- a/src/pycyphal2/can/__init__.py
+++ b/src/pycyphal2/can/__init__.py
@@ -24,9 +24,6 @@
 transport = CANTransport.new(PythonCANInterface(bus))
 ```
 
-For browser/Pyodide applications, use `pycyphal2.can.webserial.WebSerialSLCANInterface` with an
-application-provided async WebSerial byte stream adapter.
-
 Pass the transport to `pycyphal2.Node.new()` to start a node.
 
 For the available dependencies see the submodules such as `socketcan` et al.
@@ -40,7 +37,7 @@
 from ._interface import TimestampedFrame as TimestampedFrame
 from ._transport import CANTransport as CANTransport
 
-# Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan / pycyphal2.can.webserial;
+# Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan;
 # they are not eagerly imported here because they pull in optional dependencies.
 
 __all__ = ["CANTransport", "Frame", "TimestampedFrame", "Filter", "Interface"]

From 517001bcdf35897b8d09a7869772c366b939ee09 Mon Sep 17 00:00:00 2001
From: Pavel Kirienko 
Date: Tue, 16 Jun 2026 14:52:10 +0300
Subject: [PATCH 11/15] lenient parser

---
 src/pycyphal2/can/_slcan.py | 11 +++--------
 tests/can/test_slcan.py     | 27 +++++++++++++++++++++------
 tests/can/test_webserial.py |  6 +++---
 3 files changed, 27 insertions(+), 17 deletions(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index 503e85b9..042ad712 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -16,7 +16,6 @@
 _LF = 0x0A
 _BEL = 0x07
 _MAX_LINE_LENGTH = 256
-_TIMESTAMP_LENGTH = 4
 _DLC_TO_LENGTH = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
 _STRIP_CHARS = b" \t\r\n\x07\x03"
 
@@ -37,7 +36,8 @@ class SLCANParser:
     """
     Incremental SLCAN parser.
 
-    Only extended-ID data frames are returned. Unsupported or malformed input is silently dropped with debug logging.
+    Only data frames are returned. Unsupported or malformed input is silently dropped with debug logging.
+    Adapter-specific suffixes after the payload, such as timestamps or flags, are ignored.
     """
 
     def __init__(self, *, max_line_length: int = _MAX_LINE_LENGTH) -> None:
@@ -115,12 +115,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
         _logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
         return None
     expected = header_length + payload_length * 2
-    if len(line) >= expected + _TIMESTAMP_LENGTH:
-        # REFERENCE PARITY: accept and ignore extra bytes between payload and timestamp, check tail only.
-        if not _is_hex(line[-_TIMESTAMP_LENGTH:]):
-            _logger.debug("SLCAN drop malformed timestamp line=%r", line)
-            return None
-    elif len(line) != expected:
+    if len(line) < expected:
         _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
         return None
     if id_length == 3 and identifier > _CAN_STD_ID_MASK:
diff --git a/tests/can/test_slcan.py b/tests/can/test_slcan.py
index b824ef39..1165d23f 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_slcan.py
@@ -24,24 +24,30 @@ def test_parse_classic_extended_frames() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T000001232ABCD\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232abCd\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"t1231AA\r") == [Frame(id=0x123, data=b"\xaa")]
     assert parser.feed(b"t7FF1AA\r") == [Frame(id=0x7FF, data=b"\xaa")]
+    assert parser.feed(b"t7FF0\r") == [Frame(id=0x7FF, data=b"")]
     assert parser.feed(b"T000001") == []
     assert parser.feed(b"230\r") == [Frame(id=0x123, data=b"")]
     assert parser.feed(b"x1BADC0DE201AB\r") == [Frame(id=0x1BADC0DE, data=b"\x01\xab")]
 
 
-def test_parse_classic_extended_frames_with_timestamp_suffix() -> None:
+def test_parse_ignores_optional_frame_suffix() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T000001232ABCD1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
-    # REFERENCE PARITY: pydronecan accepts garbage before the 4-hex timestamp tail; keep this permissive behavior.
-    assert parser.feed(b"T000001232ABCDxxxx1234\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232ABCDL\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232ABCD1234L\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"T000001232ABCDzzzz\r") == [Frame(id=0x123, data=b"\xab\xcd")]
+    assert parser.feed(b"t1231AAL\r") == [Frame(id=0x123, data=b"\xaa")]
     assert parser.feed(b"T000001232ABCD1234\x03\r") == [Frame(id=0x123, data=b"\xab\xcd")]
     assert parser.feed(b"T10AE6EFF8000000FF000000A07071\r") == [
         Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xff\x00\x00\x00\xa0"),
     ]
-    assert parser.feed(b"T000001232ABCDzzzz\r") == []
+    assert parser.feed(b"T10AE6EFF8000000FF000000A07071Lvendor\r") == [
+        Frame(id=0x10AE6EFF, data=b"\x00\x00\x00\xff\x00\x00\x00\xa0"),
+    ]
 
 
 def test_parse_multiple_frames_and_newlines() -> None:
@@ -57,7 +63,16 @@ def test_parse_fd_and_drops_unsupported_frame_types() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"D000001239000102030405060708090A0B\r") == [Frame(id=0x123, data=bytes(range(12)))]
-    assert parser.feed(b"r1231\rR000001231\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")]
+    assert parser.feed(b"r1231\rR000001231\rR1234f00d8\rr008\rT00000123155\r") == [Frame(id=0x123, data=b"\x55")]
+
+
+def test_parse_ignores_adapter_commands_and_status_blocks() -> None:
+    parser = SLCANParser()
+
+    assert parser.feed(
+        b"\r\aS8\rO\rL\rl\rC\rMFFFFFFFF\rm123\rU1\rZ1\rF20\rV0102\r"
+        b"N00112233445566778899AABBCCDDEEFF\rT00000123155\r"
+    ) == [Frame(id=0x123, data=b"\x55")]
 
 
 def test_parse_drops_malformed_input_without_raising() -> None:
@@ -65,7 +80,7 @@ def test_parse_drops_malformed_input_without_raising() -> None:
 
     assert parser.feed(b"T00000123XAA\r") == []
     assert parser.feed(b"T000001232AA\r") == []
-    assert parser.feed(b"T000001232AABBCC\r") == []
+    assert parser.feed(b"T000001232AABBCC\r") == [Frame(id=0x123, data=b"\xaa\xbb")]
     assert parser.feed(b"T000001232AAGG\r") == []
     assert parser.feed(b"TFFFFFFFF0\r") == []
     assert parser.feed(b"tABC0\r") == []
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index 1e97e6c4..2c57672f 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -11,7 +11,7 @@
 from pycyphal2.can.webserial import AsyncSerialPort, WebSerialSLCANInterface
 
 
-class _FakeAsyncSerial:
+class _FakeAsyncSerial(AsyncSerialPort):
     def __init__(self, reads: list[bytes | BaseException] | None = None) -> None:
         self.reads = list(reads or [])
         self.writes: list[bytes] = []
@@ -106,9 +106,9 @@ async def run() -> None:
     asyncio.run(run())
 
 
-def test_receive_accepts_slcan_timestamp_suffix() -> None:
+def test_receive_accepts_slcan_optional_suffix() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([b"T10AE6EFF8000000FF000000A07071\r"])
+        port = _FakeAsyncSerial([b"T10AE6EFF8000000FF000000A07071Lvendor\r"])
         iface = WebSerialSLCANInterface(port)
 
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)

From 98f9c5e21efc7a1815f7c98d3741d550cfc3952a Mon Sep 17 00:00:00 2001
From: Pavel Kirienko 
Date: Tue, 16 Jun 2026 14:53:46 +0300
Subject: [PATCH 12/15] simplify

---
 src/pycyphal2/can/_slcan.py | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index 042ad712..0b2b83a5 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -11,7 +11,6 @@
 _CAN_EXT_ID_MASK = (1 << 29) - 1
 _CAN_STD_ID_MASK = (1 << 11) - 1
 _CAN_CLASSIC_MTU = 8
-_HEX_CHARS = frozenset(b"0123456789abcdefABCDEF")
 _CR = 0x0D
 _LF = 0x0A
 _BEL = 0x07
@@ -137,15 +136,21 @@ def _make_frame(identifier: int, data_hex: bytes) -> Frame | None:
 
 
 def _parse_hex_int(value: bytes) -> int | None:
-    if not value or not _is_hex(value):
+    if not value:
+        return None
+    try:
+        return int(value, 16)
+    except ValueError:
         return None
-    return int(value, 16)
 
 
 def _parse_hex_bytes(value: bytes) -> bytes | None:
-    if len(value) % 2 != 0 or not _is_hex(value):
+    if len(value) % 2 != 0:
+        return None
+    try:
+        return bytes.fromhex(value.decode("ascii"))
+    except (UnicodeDecodeError, ValueError):
         return None
-    return bytes.fromhex(value.decode("ascii"))
 
 
 def _parse_dlc(value: int) -> int | None:
@@ -161,6 +166,3 @@ def _parse_dlc(value: int) -> int | None:
 def _dlc_to_length(dlc: int) -> int | None:
     return _DLC_TO_LENGTH[dlc] if 0 <= dlc < len(_DLC_TO_LENGTH) else None
 
-
-def _is_hex(value: bytes) -> bool:
-    return all(x in _HEX_CHARS for x in value)

From 6867c24e5f7e0f7db87e06a1381f17e4ce4aa4cb Mon Sep 17 00:00:00 2001
From: Agent 
Date: Tue, 16 Jun 2026 14:59:17 +0300
Subject: [PATCH 13/15] Fix SLCAN formatting

---
 src/pycyphal2/can/_slcan.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index 0b2b83a5..b94c47d4 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -165,4 +165,3 @@ def _parse_dlc(value: int) -> int | None:
 
 def _dlc_to_length(dlc: int) -> int | None:
     return _DLC_TO_LENGTH[dlc] if 0 <= dlc < len(_DLC_TO_LENGTH) else None
-

From 3ba5be0015347a50d8b82d697313e7545c4ff96d Mon Sep 17 00:00:00 2001
From: Maksim Drachov 
Date: Thu, 18 Jun 2026 16:08:34 +0300
Subject: [PATCH 14/15] Fix SLCAN initialization

---
 src/pycyphal2/can/_slcan.py    |  23 +++++++-
 src/pycyphal2/can/webserial.py | 105 +++++++++++++++++++++++++++++++--
 tests/can/test_webserial.py    |  77 ++++++++++++++++++------
 3 files changed, 180 insertions(+), 25 deletions(-)

diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_slcan.py
index b94c47d4..4f08083a 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_slcan.py
@@ -11,9 +11,28 @@
 _CAN_EXT_ID_MASK = (1 << 29) - 1
 _CAN_STD_ID_MASK = (1 << 11) - 1
 _CAN_CLASSIC_MTU = 8
-_CR = 0x0D
+SLCAN_ACK = 0x0D
+SLCAN_NACK = 0x07
+SLCAN_ACK_TIMEOUT = 1.0
+SLCAN_DEFAULT_BITRATE = 1_000_000
+SLCAN_BITRATE_TO_SPEED_CODE = {
+    1_000_000: 8,
+    800_000: 7,
+    500_000: 6,
+    250_000: 5,
+    125_000: 4,
+    100_000: 3,
+    50_000: 2,
+    20_000: 1,
+    10_000: 0,
+}
+SLCAN_COMMAND_TERMINATOR = b"\r"
+SLCAN_COMMAND_CLOSE = b"C"
+SLCAN_COMMAND_OPEN = b"O"
+SLCAN_COMMAND_SET_BITRATE_PREFIX = b"S"
+_CR = SLCAN_ACK
 _LF = 0x0A
-_BEL = 0x07
+_BEL = SLCAN_NACK
 _MAX_LINE_LENGTH = 256
 _DLC_TO_LENGTH = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
 _STRIP_CHARS = b" \t\r\n\x07\x03"
diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py
index 83eb95ac..f03add4e 100644
--- a/src/pycyphal2/can/webserial.py
+++ b/src/pycyphal2/can/webserial.py
@@ -3,13 +3,25 @@
 from __future__ import annotations
 
 import asyncio
+import logging
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
-import logging
 
 from .._api import ClosedError, Instant
 from ._interface import Filter, Interface, TimestampedFrame
-from ._slcan import SLCANParser, encode_frame
+from ._slcan import (
+    SLCAN_ACK,
+    SLCAN_ACK_TIMEOUT,
+    SLCAN_BITRATE_TO_SPEED_CODE,
+    SLCAN_COMMAND_CLOSE,
+    SLCAN_COMMAND_OPEN,
+    SLCAN_COMMAND_SET_BITRATE_PREFIX,
+    SLCAN_COMMAND_TERMINATOR,
+    SLCAN_DEFAULT_BITRATE,
+    SLCAN_NACK,
+    SLCANParser,
+    encode_frame,
+)
 
 _logger = logging.getLogger(__name__)
 
@@ -34,23 +46,34 @@ class WebSerialSLCANInterface(Interface):
     """
     SLCAN CAN interface over an application-provided async serial byte stream.
 
-    The port is expected to be already opened and configured by browser/Pyodide glue code.
+    The port is expected to be already opened by browser/Pyodide glue code.
+    The SLCAN channel is closed, configured for the selected bitrate, then reopened before frame I/O begins.
     Only Classic CAN extended-ID data frames are supported.
     """
 
-    def __init__(self, port: AsyncSerialPort, *, name: str = "webserial") -> None:
+    def __init__(self, port: AsyncSerialPort, *, name: str = "webserial", bitrate: int | None = None) -> None:
         self._port = port
         self._name = str(name)
+        self._bitrate = SLCAN_DEFAULT_BITRATE if bitrate is None else int(bitrate)
+        try:
+            self._speed_code = SLCAN_BITRATE_TO_SPEED_CODE[self._bitrate]
+        except KeyError:
+            raise ValueError(f"Unsupported SLCAN bitrate: {bitrate!r}") from None
         self._closed = False
         self._failure: BaseException | None = None
         self._parser = SLCANParser()
         self._tx_seq = 0
         self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
         self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue()
+        self._init_task: asyncio.Task[None] | None = None
         self._tx_task: asyncio.Task[None] | None = None
         self._rx_task: asyncio.Task[None] | None = None
         self._close_task: asyncio.Task[None] | None = None
-        _logger.info("WebSerial SLCAN init iface=%s", self._name)
+        try:
+            self._start_init()
+        except RuntimeError:
+            pass
+        _logger.info("WebSerial SLCAN init iface=%s bitrate=%d", self._name, self._bitrate)
 
     @property
     def name(self) -> str:
@@ -107,6 +130,13 @@ def __repr__(self) -> str:
         return f"{type(self).__name__}({self._name!r}, fd={self.fd})"
 
     async def _tx_loop(self) -> None:
+        try:
+            await self._ensure_initialized()
+        except asyncio.CancelledError:
+            raise
+        except Exception as ex:
+            self._fail(ex)
+            return
         while not self._closed:
             try:
                 identifier, seq, deadline_ns, payload = await self._tx_queue.get()
@@ -133,6 +163,13 @@ async def _tx_loop(self) -> None:
                 return
 
     async def _rx_loop(self) -> None:
+        try:
+            await self._ensure_initialized()
+        except asyncio.CancelledError:
+            raise
+        except Exception as ex:
+            self._fail(ex)
+            return
         while not self._closed:
             try:
                 chunk = await self._port.read()
@@ -147,6 +184,61 @@ async def _rx_loop(self) -> None:
             for frame in self._parser.feed(chunk):
                 self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
 
+    def _start_init(self) -> None:
+        if self._init_task is None:
+            self._init_task = asyncio.get_running_loop().create_task(self._init_adapter())
+            self._init_task.add_done_callback(self._on_init_done)
+
+    def _on_init_done(self, task: asyncio.Task[None]) -> None:
+        if task.cancelled():
+            return
+        try:
+            task.result()
+        except Exception as ex:
+            if not self._closed:
+                self._fail(ex)
+
+    async def _ensure_initialized(self) -> None:
+        self._raise_if_closed()
+        self._start_init()
+        assert self._init_task is not None
+        await asyncio.shield(self._init_task)
+        self._raise_if_closed()
+
+    async def _init_adapter(self) -> None:
+        _logger.info("WebSerial SLCAN setup iface=%s bitrate=%d", self._name, self._bitrate)
+        await self._send_init_command(SLCAN_COMMAND_CLOSE, optional_ack=True)
+        await self._send_init_command(SLCAN_COMMAND_SET_BITRATE_PREFIX + str(self._speed_code).encode("ascii"))
+        await self._send_init_command(SLCAN_COMMAND_OPEN)
+        _logger.info("WebSerial SLCAN setup done iface=%s", self._name)
+
+    async def _send_init_command(self, command: bytes, *, optional_ack: bool = False) -> None:
+        _logger.debug("WebSerial SLCAN setup cmd iface=%s cmd=%r", self._name, command)
+        await self._port.write(command + SLCAN_COMMAND_TERMINATOR)
+        try:
+            await self._wait_for_init_ack()
+        except Exception as ex:
+            if not optional_ack:
+                raise
+            _logger.debug("WebSerial SLCAN setup ignored cmd error iface=%s cmd=%r err=%s", self._name, command, ex)
+
+    async def _wait_for_init_ack(self) -> None:
+        loop = asyncio.get_running_loop()
+        deadline = loop.time() + SLCAN_ACK_TIMEOUT
+        while True:
+            timeout = deadline - loop.time()
+            if timeout <= 0.0:
+                raise TimeoutError("SLCAN ACK timeout")
+            chunk = await asyncio.wait_for(self._port.read(), timeout=timeout)
+            if not chunk:
+                raise EOFError("SLCAN channel ended while waiting for ACK")
+            for byte in chunk:
+                if byte == SLCAN_ACK:
+                    return
+                if byte == SLCAN_NACK:
+                    raise OSError("SLCAN NACK in response")
+                _logger.debug("WebSerial SLCAN setup ignored byte iface=%s byte=%02x", self._name, byte)
+
     def _fail(self, ex: BaseException) -> None:
         if self._failure is None:
             self._failure = ex
@@ -168,9 +260,10 @@ def _cancel_worker_tasks(self) -> None:
             current = asyncio.current_task()
         except RuntimeError:
             current = None
-        for task in (self._tx_task, self._rx_task):
+        for task in (self._init_task, self._tx_task, self._rx_task):
             if task is not None and task is not current:
                 task.cancel()
+        self._init_task = None
         self._tx_task = None
         self._rx_task = None
 
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index 2c57672f..04819cac 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -10,6 +10,11 @@
 from pycyphal2.can import Filter, TimestampedFrame
 from pycyphal2.can.webserial import AsyncSerialPort, WebSerialSLCANInterface
 
+_ACK = b"\r"
+_NACK = b"\x07"
+_INIT_1M = [b"C\r", b"S8\r", b"O\r"]
+_INIT_250K = [b"C\r", b"S5\r", b"O\r"]
+
 
 class _FakeAsyncSerial(AsyncSerialPort):
     def __init__(self, reads: list[bytes | BaseException] | None = None) -> None:
@@ -62,6 +67,10 @@ def test_async_serial_port_is_abc() -> None:
     assert issubclass(AsyncSerialPort, ABC)
 
 
+def _init_reads(*later: bytes | BaseException) -> list[bytes | BaseException]:
+    return [_ACK, _ACK, _ACK, *later]
+
+
 def test_webserial_interface_properties_and_sync_close() -> None:
     port = _FakeAsyncSerial()
     iface = WebSerialSLCANInterface(port, name="slcan-web")
@@ -75,14 +84,47 @@ def test_webserial_interface_properties_and_sync_close() -> None:
     assert port.closed is True
 
 
+def test_webserial_initializes_slcan_channel_with_bitrate() -> None:
+    async def run() -> None:
+        port = _FakeAsyncSerial(_init_reads())
+        iface = WebSerialSLCANInterface(port, bitrate=250_000)
+
+        await _wait_for(lambda: len(port.writes) == len(_INIT_250K))
+        assert port.writes == _INIT_250K
+
+        iface.close()
+        await _wait_for(lambda: port.closed)
+
+    asyncio.run(run())
+
+
+def test_webserial_rejects_unsupported_bitrate() -> None:
+    with pytest.raises(ValueError, match="Unsupported SLCAN bitrate"):
+        WebSerialSLCANInterface(_FakeAsyncSerial(), bitrate=123_456)
+
+
+def test_webserial_initialization_nack_closes_interface() -> None:
+    async def run() -> None:
+        port = _FakeAsyncSerial([_ACK, _NACK])
+        iface = WebSerialSLCANInterface(port)
+
+        await _wait_for(lambda: port.closed)
+        assert port.writes == [b"C\r", b"S8\r"]
+        with pytest.raises(ClosedError, match="failed") as exc_info:
+            iface.filter([Filter.promiscuous()])
+        assert isinstance(exc_info.value.__cause__, OSError)
+
+    asyncio.run(run())
+
+
 def test_enqueue_writes_expected_slcan_bytes() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial()
+        port = _FakeAsyncSerial(_init_reads())
         iface = WebSerialSLCANInterface(port)
         iface.enqueue(0x123, [memoryview(b"\xaa"), memoryview(b"")], Instant.now() + 1.0)
 
-        await _wait_for(lambda: len(port.writes) == 2)
-        assert port.writes == [b"T000001231AA\r", b"T000001230\r"]
+        await _wait_for(lambda: len(port.writes) == len(_INIT_1M) + 2)
+        assert port.writes == [*_INIT_1M, b"T000001231AA\r", b"T000001230\r"]
 
         iface.close()
         await _wait_for(lambda: port.closed)
@@ -92,7 +134,7 @@ async def run() -> None:
 
 def test_receive_returns_timestamped_frame_and_drops_malformed_input() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([b"bad\rT000001231AA\r"])
+        port = _FakeAsyncSerial(_init_reads(b"bad\rT000001231AA\r"))
         iface = WebSerialSLCANInterface(port)
 
         before = Instant.now()
@@ -108,7 +150,7 @@ async def run() -> None:
 
 def test_receive_accepts_slcan_optional_suffix() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([b"T10AE6EFF8000000FF000000A07071Lvendor\r"])
+        port = _FakeAsyncSerial(_init_reads(b"T10AE6EFF8000000FF000000A07071Lvendor\r"))
         iface = WebSerialSLCANInterface(port)
 
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
@@ -122,7 +164,7 @@ async def run() -> None:
 
 def test_receive_filter_is_noop() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([b"T000001231AA\rT000004561BB\r"])
+        port = _FakeAsyncSerial(_init_reads(b"T000001231AA\rT000004561BB\r"))
         iface = WebSerialSLCANInterface(port)
         iface.filter([Filter(id=0x456, mask=0x1FFFFFFF)])
 
@@ -137,14 +179,14 @@ async def run() -> None:
 
 def test_expired_deadline_is_dropped() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial()
+        port = _FakeAsyncSerial(_init_reads())
         iface = WebSerialSLCANInterface(port)
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + (-1.0))
         iface.enqueue(0x124, [memoryview(b"\xbb")], Instant.now() + 1.0)
 
-        await _wait_for(lambda: len(port.writes) == 1)
-        assert port.writes == [b"T000001241BB\r"]
+        await _wait_for(lambda: len(port.writes) == len(_INIT_1M) + 1)
+        assert port.writes == [*_INIT_1M, b"T000001241BB\r"]
         iface.close()
 
     asyncio.run(run())
@@ -152,15 +194,15 @@ async def run() -> None:
 
 def test_purge_drops_pending_tx() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial()
+        port = _FakeAsyncSerial(_init_reads())
         iface = WebSerialSLCANInterface(port)
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 10.0)
         iface.purge()
         iface.enqueue(0x124, [memoryview(b"\xbb")], Instant.now() + 1.0)
 
-        await _wait_for(lambda: len(port.writes) == 1)
-        assert port.writes == [b"T000001241BB\r"]
+        await _wait_for(lambda: len(port.writes) == len(_INIT_1M) + 1)
+        assert port.writes == [*_INIT_1M, b"T000001241BB\r"]
         iface.close()
 
     asyncio.run(run())
@@ -168,7 +210,7 @@ async def run() -> None:
 
 def test_close_unblocks_pending_receive_and_operations_raise() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial()
+        port = _FakeAsyncSerial(_init_reads())
         iface = WebSerialSLCANInterface(port)
         task = asyncio.create_task(iface.receive())
         await asyncio.sleep(0)
@@ -189,7 +231,7 @@ async def run() -> None:
 
 def test_read_failure_closes_interface() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([OSError("rx failed")])
+        port = _FakeAsyncSerial(_init_reads(OSError("rx failed")))
         iface = WebSerialSLCANInterface(port)
 
         with pytest.raises(ClosedError, match="receive failed") as exc_info:
@@ -206,9 +248,10 @@ async def run() -> None:
 
 def test_write_failure_closes_interface() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial()
-        port.write_errors.append(OSError("tx failed"))
+        port = _FakeAsyncSerial(_init_reads())
         iface = WebSerialSLCANInterface(port)
+        await _wait_for(lambda: len(port.writes) == len(_INIT_1M))
+        port.write_errors.append(OSError("tx failed"))
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 1.0)
 
@@ -222,7 +265,7 @@ async def run() -> None:
 
 def test_enqueue_validation() -> None:
     async def run() -> None:
-        iface = WebSerialSLCANInterface(_FakeAsyncSerial())
+        iface = WebSerialSLCANInterface(_FakeAsyncSerial(_init_reads()))
 
         with pytest.raises(ValueError, match="Invalid CAN identifier"):
             iface.enqueue(-1, [memoryview(b"")], Instant.now())

From 53d915e57824edb47bcea56250509058141d56d7 Mon Sep 17 00:00:00 2001
From: Pavel Kirienko 
Date: Fri, 19 Jun 2026 16:46:49 +0300
Subject: [PATCH 15/15] Rename SLCAN to media module and tighten the
 abstraction

- Rename _slcan.py -> _media_slcan.py (it is a medium driver, not the stack).
- Import CAN wire constants from _wire.py instead of redefining them.
- Hide all low-level SLCAN constants behind encode_deinit/encode_init_sequence/
  classify_init_response; webserial no longer composes raw command bytes.
- No default bitrate is guessed; non-standard bitrates are sent as-is.
- Reset adapters in unknown states: close, settle, purge stale input, then open.
- Audit exception handling; drop the unsound init RuntimeError swallow and dead
  CancelledError clauses.
- Bump version to 2.0.0.dev3.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CHANGELOG.rst                                 |   2 +
 src/pycyphal2/__init__.py                     |   2 +-
 .../can/{_slcan.py => _media_slcan.py}        |  99 ++++++------
 src/pycyphal2/can/webserial.py                | 107 ++++++-------
 .../{test_slcan.py => test_media_slcan.py}    |  39 ++++-
 tests/can/test_webserial.py                   | 144 +++++++++++++-----
 6 files changed, 243 insertions(+), 150 deletions(-)
 rename src/pycyphal2/can/{_slcan.py => _media_slcan.py} (71%)
 rename tests/can/{test_slcan.py => test_media_slcan.py} (76%)

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6f2ecbf1..f1664bde 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -16,6 +16,8 @@ on top of Cyphal v1.0.
 Due to the significant changes, the new version is published under a different name ``pycyphal2`` to allow coexistence
 with v1 in the same Python environment.
 
+- Add Cyphal/CAN SLCAN media with a browser WebSerial backend.
+
 Changelog v1
 ============
 
diff --git a/src/pycyphal2/__init__.py b/src/pycyphal2/__init__.py
index 88043d9b..1079b2bf 100644
--- a/src/pycyphal2/__init__.py
+++ b/src/pycyphal2/__init__.py
@@ -155,7 +155,7 @@ async def main():
 from ._transport import Transport as Transport
 from ._transport import TransportArrival as TransportArrival
 
-__version__ = "2.0.0.dev2"
+__version__ = "2.0.0.dev3"
 
 # pdoc needs __all__ to display re-exported members.
 __all__ = [
diff --git a/src/pycyphal2/can/_slcan.py b/src/pycyphal2/can/_media_slcan.py
similarity index 71%
rename from src/pycyphal2/can/_slcan.py
rename to src/pycyphal2/can/_media_slcan.py
index 4f08083a..16bbfae3 100644
--- a/src/pycyphal2/can/_slcan.py
+++ b/src/pycyphal2/can/_media_slcan.py
@@ -1,21 +1,28 @@
-"""SLCAN text codec for CAN frames."""
+"""
+SLCAN text protocol for CAN media (frame codec + adapter handshake).
+"""
 
 from __future__ import annotations
 
 import logging
 
 from ._interface import Frame
+from ._wire import CAN_EXT_ID_MASK, DLC_TO_LENGTH, MTU_CAN_CLASSIC
 
 _logger = logging.getLogger(__name__)
 
-_CAN_EXT_ID_MASK = (1 << 29) - 1
 _CAN_STD_ID_MASK = (1 << 11) - 1
-_CAN_CLASSIC_MTU = 8
-SLCAN_ACK = 0x0D
-SLCAN_NACK = 0x07
-SLCAN_ACK_TIMEOUT = 1.0
-SLCAN_DEFAULT_BITRATE = 1_000_000
-SLCAN_BITRATE_TO_SPEED_CODE = {
+_CR = 0x0D  # ACK / carriage return
+_LF = 0x0A
+_BEL = 0x07  # NACK / bell
+_MAX_LINE_LENGTH = 256
+_STRIP_CHARS = b" \t\r\n\x07\x03"
+
+_CMD_TERMINATOR = bytes([_CR])
+_CMD_CLOSE = b"C"
+_CMD_OPEN = b"O"
+_CMD_SET_BITRATE_PREFIX = b"S"
+_BITRATE_TO_SPEED_CODE = {
     1_000_000: 8,
     800_000: 7,
     500_000: 6,
@@ -26,42 +33,58 @@
     20_000: 1,
     10_000: 0,
 }
-SLCAN_COMMAND_TERMINATOR = b"\r"
-SLCAN_COMMAND_CLOSE = b"C"
-SLCAN_COMMAND_OPEN = b"O"
-SLCAN_COMMAND_SET_BITRATE_PREFIX = b"S"
-_CR = SLCAN_ACK
-_LF = 0x0A
-_BEL = SLCAN_NACK
-_MAX_LINE_LENGTH = 256
-_DLC_TO_LENGTH = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
-_STRIP_CHARS = b" \t\r\n\x07\x03"
 
 
 def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes:
     """
     Encode an extended-ID Classic CAN data frame into one SLCAN ``T`` command line.
     """
-    if not isinstance(identifier, int) or not (0 <= identifier <= _CAN_EXT_ID_MASK):
+    if not isinstance(identifier, int) or not (0 <= identifier <= CAN_EXT_ID_MASK):
         raise ValueError(f"Invalid CAN identifier: {identifier!r}")
     payload = bytes(data)
-    if len(payload) > _CAN_CLASSIC_MTU:
+    if len(payload) > MTU_CAN_CLASSIC:
         raise ValueError(f"Invalid CAN data length: {len(payload)}")
     return f"T{identifier:08X}{len(payload):1d}{payload.hex().upper()}\r".encode()
 
 
+def encode_deinit() -> bytes:
+    """The close command line. Sent fire-and-forget before purging input to reset the adapter."""
+    return _CMD_CLOSE + _CMD_TERMINATOR
+
+
+def encode_init_sequence(bitrate: int | None) -> list[bytes]:
+    """
+    Command lines to bring the adapter up after deinit+purge: optionally set the bitrate, then open.
+    If ``bitrate`` is None, the bitrate command is skipped, the old configured value (or adapter default) is kept.
+    A bitrate not in the standard speed-code table is sent as-is (some adapters accept raw bitrates, e.g. Zubax).
+    Each returned command line expects an ACK.
+    """
+    out: list[bytes] = []
+    if bitrate is not None:
+        code = _BITRATE_TO_SPEED_CODE.get(bitrate, bitrate)
+        out.append(_CMD_SET_BITRATE_PREFIX + str(code).encode("ascii") + _CMD_TERMINATOR)
+    out.append(_CMD_OPEN + _CMD_TERMINATOR)
+    return out
+
+
+def classify_init_response(chunk: bytes) -> bool | None:
+    """Scan a chunk for the first ACK (True) or NACK (False); return None if neither appears."""
+    for byte in chunk:
+        if byte == _CR:
+            return True
+        if byte == _BEL:
+            return False
+    return None
+
+
 class SLCANParser:
     """
     Incremental SLCAN parser.
-
     Only data frames are returned. Unsupported or malformed input is silently dropped with debug logging.
     Adapter-specific suffixes after the payload, such as timestamps or flags, are ignored.
     """
 
-    def __init__(self, *, max_line_length: int = _MAX_LINE_LENGTH) -> None:
-        if max_line_length < 10:
-            raise ValueError(f"Invalid maximum SLCAN line length: {max_line_length}")
-        self._max_line_length = int(max_line_length)
+    def __init__(self) -> None:
         self._buffer = bytearray()
         self._discarding = False
 
@@ -87,8 +110,8 @@ def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
                 continue
             if self._discarding:
                 continue
-            if len(self._buffer) >= self._max_line_length:
-                _logger.debug("SLCAN drop overlong line len>%d", self._max_line_length)
+            if len(self._buffer) >= _MAX_LINE_LENGTH:
+                _logger.debug("SLCAN drop overlong line len>%d", _MAX_LINE_LENGTH)
                 self._buffer.clear()
                 self._discarding = True
                 continue
@@ -97,15 +120,16 @@ def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
 
 
 def _parse_line(line: bytes) -> Frame | None:
-    # REFERENCE PARITY: pydronecan strips surrounding whitespace and control characters like BEL/ETX.
+    # Based on the original PyUAVCAN/PyDroneCAN implementation.
+    # Strips surrounding whitespace and control characters like BEL/ETX.
     line = line.strip(_STRIP_CHARS)
     if not line:
         return None
     command = line[:1]
     if command in (b"T", b"x"):
-        return _parse_data_frame(line, id_length=8, max_payload_length=_CAN_CLASSIC_MTU)
+        return _parse_data_frame(line, id_length=8, max_payload_length=MTU_CAN_CLASSIC)
     if command == b"t":
-        return _parse_data_frame(line, id_length=3, max_payload_length=_CAN_CLASSIC_MTU)
+        return _parse_data_frame(line, id_length=3, max_payload_length=MTU_CAN_CLASSIC)
     if command == b"D":
         return _parse_data_frame(line, id_length=8, max_payload_length=64)
     if command in (b"r", b"R"):
@@ -125,10 +149,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
     if identifier is None or dlc is None:
         _logger.debug("SLCAN drop malformed data header line=%r", line)
         return None
-    payload_length = _dlc_to_length(dlc)
-    if payload_length is None:
-        _logger.debug("SLCAN drop malformed dlc=%r line=%r", dlc, line)
-        return None
+    payload_length = DLC_TO_LENGTH[dlc]  # _parse_dlc guarantees dlc in [0, 15]
     if payload_length > max_payload_length:
         _logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
         return None
@@ -139,11 +160,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
     if id_length == 3 and identifier > _CAN_STD_ID_MASK:
         _logger.debug("SLCAN drop invalid standard id=%x", identifier)
         return None
-    return _make_frame(identifier, line[header_length:expected])
-
-
-def _make_frame(identifier: int, data_hex: bytes) -> Frame | None:
-    data = _parse_hex_bytes(data_hex)
+    data = _parse_hex_bytes(line[header_length:expected])
     if data is None:
         _logger.debug("SLCAN drop malformed data id=%08x", identifier)
         return None
@@ -180,7 +197,3 @@ def _parse_dlc(value: int) -> int | None:
     if ord("a") <= value <= ord("f"):
         return 10 + value - ord("a")
     return None
-
-
-def _dlc_to_length(dlc: int) -> int | None:
-    return _DLC_TO_LENGTH[dlc] if 0 <= dlc < len(_DLC_TO_LENGTH) else None
diff --git a/src/pycyphal2/can/webserial.py b/src/pycyphal2/can/webserial.py
index f03add4e..586dd0f1 100644
--- a/src/pycyphal2/can/webserial.py
+++ b/src/pycyphal2/can/webserial.py
@@ -1,4 +1,6 @@
-"""Browser-oriented SLCAN backend for WebSerial/Pyodide."""
+"""
+Browser-oriented SLCAN backend for WebSerial/Pyodide.
+"""
 
 from __future__ import annotations
 
@@ -9,22 +11,20 @@
 
 from .._api import ClosedError, Instant
 from ._interface import Filter, Interface, TimestampedFrame
-from ._slcan import (
-    SLCAN_ACK,
-    SLCAN_ACK_TIMEOUT,
-    SLCAN_BITRATE_TO_SPEED_CODE,
-    SLCAN_COMMAND_CLOSE,
-    SLCAN_COMMAND_OPEN,
-    SLCAN_COMMAND_SET_BITRATE_PREFIX,
-    SLCAN_COMMAND_TERMINATOR,
-    SLCAN_DEFAULT_BITRATE,
-    SLCAN_NACK,
+from ._media_slcan import (
     SLCANParser,
+    classify_init_response,
+    encode_deinit,
     encode_frame,
+    encode_init_sequence,
 )
 
 _logger = logging.getLogger(__name__)
 
+_ACK_TIMEOUT = 3.0
+_DEINIT_SETTLE = 0.1
+_PURGE_DRAIN_TIMEOUT = 0.05
+
 
 class AsyncSerialPort(ABC):
     """Minimal async byte stream expected from a WebSerial adapter."""
@@ -47,18 +47,15 @@ class WebSerialSLCANInterface(Interface):
     SLCAN CAN interface over an application-provided async serial byte stream.
 
     The port is expected to be already opened by browser/Pyodide glue code.
-    The SLCAN channel is closed, configured for the selected bitrate, then reopened before frame I/O begins.
-    Only Classic CAN extended-ID data frames are supported.
+    On startup the adapter is reset: closed, left to settle, its pending input purged (so stale frames
+    from a previous configuration are dropped), then configured for the selected bitrate and reopened.
+    If no bitrate is given, the bitrate is left unconfigured (old/default).
     """
 
     def __init__(self, port: AsyncSerialPort, *, name: str = "webserial", bitrate: int | None = None) -> None:
         self._port = port
         self._name = str(name)
-        self._bitrate = SLCAN_DEFAULT_BITRATE if bitrate is None else int(bitrate)
-        try:
-            self._speed_code = SLCAN_BITRATE_TO_SPEED_CODE[self._bitrate]
-        except KeyError:
-            raise ValueError(f"Unsupported SLCAN bitrate: {bitrate!r}") from None
+        self._bitrate = None if bitrate is None else int(bitrate)
         self._closed = False
         self._failure: BaseException | None = None
         self._parser = SLCANParser()
@@ -69,11 +66,8 @@ def __init__(self, port: AsyncSerialPort, *, name: str = "webserial", bitrate: i
         self._tx_task: asyncio.Task[None] | None = None
         self._rx_task: asyncio.Task[None] | None = None
         self._close_task: asyncio.Task[None] | None = None
-        try:
-            self._start_init()
-        except RuntimeError:
-            pass
-        _logger.info("WebSerial SLCAN init iface=%s bitrate=%d", self._name, self._bitrate)
+        self._start_init()  # Requires a running loop; this is an async interface, always built in one.
+        _logger.info("WebSerial SLCAN init iface=%s bitrate=%s", self._name, self._bitrate)
 
     @property
     def name(self) -> str:
@@ -132,22 +126,14 @@ def __repr__(self) -> str:
     async def _tx_loop(self) -> None:
         try:
             await self._ensure_initialized()
-        except asyncio.CancelledError:
-            raise
         except Exception as ex:
             self._fail(ex)
             return
         while not self._closed:
-            try:
-                identifier, seq, deadline_ns, payload = await self._tx_queue.get()
-            except asyncio.CancelledError:
-                raise
+            identifier, seq, deadline_ns, payload = await self._tx_queue.get()
             if self._closed:
                 return
-            if Instant.now().ns >= deadline_ns:
-                _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier)
-                continue
-            timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
+            timeout = (deadline_ns - Instant.now().ns) * 1e-9
             if timeout <= 0.0:
                 _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier)
                 continue
@@ -156,8 +142,6 @@ async def _tx_loop(self) -> None:
             except asyncio.TimeoutError:
                 self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
                 await asyncio.sleep(0.001)
-            except asyncio.CancelledError:
-                raise
             except Exception as ex:
                 self._fail(ex)
                 return
@@ -165,16 +149,12 @@ async def _tx_loop(self) -> None:
     async def _rx_loop(self) -> None:
         try:
             await self._ensure_initialized()
-        except asyncio.CancelledError:
-            raise
         except Exception as ex:
             self._fail(ex)
             return
         while not self._closed:
             try:
                 chunk = await self._port.read()
-            except asyncio.CancelledError:
-                raise
             except Exception as ex:
                 self._fail(ex)
                 return
@@ -206,25 +186,34 @@ async def _ensure_initialized(self) -> None:
         self._raise_if_closed()
 
     async def _init_adapter(self) -> None:
-        _logger.info("WebSerial SLCAN setup iface=%s bitrate=%d", self._name, self._bitrate)
-        await self._send_init_command(SLCAN_COMMAND_CLOSE, optional_ack=True)
-        await self._send_init_command(SLCAN_COMMAND_SET_BITRATE_PREFIX + str(self._speed_code).encode("ascii"))
-        await self._send_init_command(SLCAN_COMMAND_OPEN)
+        _logger.info("WebSerial SLCAN setup iface=%s bitrate=%s", self._name, self._bitrate)
+        # Reset an adapter that may be in an unknown state: close, settle, discard whatever it was
+        # forwarding under the old config, then configure and open.
+        await self._port.write(encode_deinit())
+        await asyncio.sleep(_DEINIT_SETTLE)
+        await self._purge_input()
+        for command in encode_init_sequence(self._bitrate):
+            _logger.debug("WebSerial SLCAN setup cmd iface=%s cmd=%r", self._name, command)
+            await self._port.write(command)
+            await self._wait_for_init_ack()
         _logger.info("WebSerial SLCAN setup done iface=%s", self._name)
 
-    async def _send_init_command(self, command: bytes, *, optional_ack: bool = False) -> None:
-        _logger.debug("WebSerial SLCAN setup cmd iface=%s cmd=%r", self._name, command)
-        await self._port.write(command + SLCAN_COMMAND_TERMINATOR)
-        try:
-            await self._wait_for_init_ack()
-        except Exception as ex:
-            if not optional_ack:
-                raise
-            _logger.debug("WebSerial SLCAN setup ignored cmd error iface=%s cmd=%r err=%s", self._name, command, ex)
+    async def _purge_input(self) -> None:
+        dropped = 0
+        while True:
+            try:
+                chunk = await asyncio.wait_for(self._port.read(), timeout=_PURGE_DRAIN_TIMEOUT)
+            except asyncio.TimeoutError:
+                break
+            if not chunk:
+                raise EOFError("SLCAN channel ended while purging input")
+            dropped += len(chunk)
+        if dropped > 0:
+            _logger.debug("WebSerial SLCAN purge stale input iface=%s dropped=%d", self._name, dropped)
 
     async def _wait_for_init_ack(self) -> None:
         loop = asyncio.get_running_loop()
-        deadline = loop.time() + SLCAN_ACK_TIMEOUT
+        deadline = loop.time() + _ACK_TIMEOUT
         while True:
             timeout = deadline - loop.time()
             if timeout <= 0.0:
@@ -232,12 +221,12 @@ async def _wait_for_init_ack(self) -> None:
             chunk = await asyncio.wait_for(self._port.read(), timeout=timeout)
             if not chunk:
                 raise EOFError("SLCAN channel ended while waiting for ACK")
-            for byte in chunk:
-                if byte == SLCAN_ACK:
-                    return
-                if byte == SLCAN_NACK:
-                    raise OSError("SLCAN NACK in response")
-                _logger.debug("WebSerial SLCAN setup ignored byte iface=%s byte=%02x", self._name, byte)
+            response = classify_init_response(chunk)
+            if response is True:
+                return
+            if response is False:
+                raise OSError("SLCAN NACK in response")
+            _logger.debug("WebSerial SLCAN setup ignored bytes iface=%s len=%d", self._name, len(chunk))
 
     def _fail(self, ex: BaseException) -> None:
         if self._failure is None:
diff --git a/tests/can/test_slcan.py b/tests/can/test_media_slcan.py
similarity index 76%
rename from tests/can/test_slcan.py
rename to tests/can/test_media_slcan.py
index 1165d23f..4578f461 100644
--- a/tests/can/test_slcan.py
+++ b/tests/can/test_media_slcan.py
@@ -3,7 +3,13 @@
 import pytest
 
 from pycyphal2.can import Frame
-from pycyphal2.can._slcan import SLCANParser, encode_frame
+from pycyphal2.can._media_slcan import (
+    SLCANParser,
+    classify_init_response,
+    encode_deinit,
+    encode_frame,
+    encode_init_sequence,
+)
 
 
 def test_encode_classic_extended_frames() -> None:
@@ -20,6 +26,28 @@ def test_encode_validation() -> None:
         encode_frame(0x123, bytes(range(9)))
 
 
+def test_encode_deinit() -> None:
+    assert encode_deinit() == b"C\r"
+
+
+def test_encode_init_sequence() -> None:
+    assert encode_init_sequence(None) == [b"O\r"]  # No bitrate is guessed.
+    assert encode_init_sequence(1_000_000) == [b"S8\r", b"O\r"]
+    assert encode_init_sequence(250_000) == [b"S5\r", b"O\r"]
+    assert encode_init_sequence(10_000) == [b"S0\r", b"O\r"]
+    # Non-standard bitrate is passed through as-is rather than rejected.
+    assert encode_init_sequence(123_456) == [b"S123456\r", b"O\r"]
+
+
+def test_classify_init_response() -> None:
+    assert classify_init_response(b"\r") is True
+    assert classify_init_response(b"\x07") is False
+    assert classify_init_response(b"xyz\rmore") is True  # First ACK wins.
+    assert classify_init_response(b"xyz\x07") is False
+    assert classify_init_response(b"") is None
+    assert classify_init_response(b"junk") is None
+
+
 def test_parse_classic_extended_frames() -> None:
     parser = SLCANParser()
 
@@ -88,9 +116,9 @@ def test_parse_drops_malformed_input_without_raising() -> None:
 
 
 def test_parser_bounds_overlong_input() -> None:
-    parser = SLCANParser(max_line_length=10)
+    parser = SLCANParser()
 
-    assert parser.feed(b"T00000123155") == []
+    assert parser.feed(b"T" + b"0" * 300) == []  # Exceeds the fixed line-length bound, dropped.
     assert parser.feed(b"\rT000001230\r") == [Frame(id=0x123, data=b"")]
 
 
@@ -98,8 +126,3 @@ def test_parser_bel_drops_buffered_error_response() -> None:
     parser = SLCANParser()
 
     assert parser.feed(b"T00000123155\aT00000123166\r") == [Frame(id=0x123, data=b"\x66")]
-
-
-def test_parser_rejects_invalid_buffer_limit() -> None:
-    with pytest.raises(ValueError, match="Invalid maximum SLCAN line length"):
-        SLCANParser(max_line_length=9)
diff --git a/tests/can/test_webserial.py b/tests/can/test_webserial.py
index 04819cac..56afdd58 100644
--- a/tests/can/test_webserial.py
+++ b/tests/can/test_webserial.py
@@ -8,6 +8,7 @@
 
 from pycyphal2 import ClosedError, Instant
 from pycyphal2.can import Filter, TimestampedFrame
+from pycyphal2.can._media_slcan import encode_init_sequence
 from pycyphal2.can.webserial import AsyncSerialPort, WebSerialSLCANInterface
 
 _ACK = b"\r"
@@ -32,7 +33,13 @@ async def read(self) -> bytes:
             return item
         loop = asyncio.get_running_loop()
         self._read_waiter = loop.create_future()
-        return await self._read_waiter
+        try:
+            return await self._read_waiter
+        finally:
+            # A timed-out (cancelled) read must not leave an orphaned waiter, else a later feed()
+            # would resolve a future nobody awaits and drop the byte. Reset so feed() falls back to
+            # the queue. This mirrors the purge draining input via short-timeout reads.
+            self._read_waiter = None
 
     async def write(self, data: bytes) -> None:
         self.writes.append(bytes(data))
@@ -63,33 +70,49 @@ async def _wait_for(predicate: Callable[[], bool], timeout: float = 1.0) -> None
     raise AssertionError("predicate did not become true within timeout")
 
 
-def test_async_serial_port_is_abc() -> None:
-    assert issubclass(AsyncSerialPort, ABC)
+def _writes_reach(port: _FakeAsyncSerial, count: int) -> Callable[[], bool]:
+    return lambda: len(port.writes) >= count
 
 
-def _init_reads(*later: bytes | BaseException) -> list[bytes | BaseException]:
-    return [_ACK, _ACK, _ACK, *later]
+async def _start(
+    port: _FakeAsyncSerial, *, bitrate: int | None = 1_000_000, name: str = "webserial"
+) -> WebSerialSLCANInterface:
+    """Construct the interface and drive the deinit/purge/init handshake to completion.
 
+    The close (write 0) is fire-and-forget and its response is purged; each subsequent init command
+    is acknowledged reactively, after its write appears, to mirror a real adapter.
+    """
+    iface = WebSerialSLCANInterface(port, name=name, bitrate=bitrate)
+    for i in range(len(encode_init_sequence(bitrate))):
+        await _wait_for(_writes_reach(port, i + 2))  # writes: [close, cmd_0, ..., cmd_i]
+        port.feed(_ACK)
+    return iface
 
-def test_webserial_interface_properties_and_sync_close() -> None:
-    port = _FakeAsyncSerial()
-    iface = WebSerialSLCANInterface(port, name="slcan-web")
 
-    assert iface.name == "slcan-web"
-    assert iface.fd is False
-    assert repr(iface) == "WebSerialSLCANInterface('slcan-web', fd=False)"
+def test_async_serial_port_is_abc() -> None:
+    assert issubclass(AsyncSerialPort, ABC)
+
 
-    iface.close()
+def test_webserial_interface_properties_and_sync_close() -> None:
+    async def run() -> tuple[WebSerialSLCANInterface, _FakeAsyncSerial]:
+        port = _FakeAsyncSerial()
+        iface = WebSerialSLCANInterface(port, name="slcan-web")
+        assert iface.name == "slcan-web"
+        assert iface.fd is False
+        assert repr(iface) == "WebSerialSLCANInterface('slcan-web', fd=False)"
+        return iface, port
+
+    iface, port = asyncio.run(run())
+    iface.close()  # No running loop anymore: close must still tear down the port.
     iface.close()
     assert port.closed is True
 
 
 def test_webserial_initializes_slcan_channel_with_bitrate() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
-        iface = WebSerialSLCANInterface(port, bitrate=250_000)
+        port = _FakeAsyncSerial()
+        iface = await _start(port, bitrate=250_000)
 
-        await _wait_for(lambda: len(port.writes) == len(_INIT_250K))
         assert port.writes == _INIT_250K
 
         iface.close()
@@ -98,15 +121,55 @@ async def run() -> None:
     asyncio.run(run())
 
 
-def test_webserial_rejects_unsupported_bitrate() -> None:
-    with pytest.raises(ValueError, match="Unsupported SLCAN bitrate"):
-        WebSerialSLCANInterface(_FakeAsyncSerial(), bitrate=123_456)
+def test_webserial_initializes_without_bitrate() -> None:
+    async def run() -> None:
+        port = _FakeAsyncSerial()
+        iface = await _start(port, bitrate=None)
+
+        assert port.writes == [b"C\r", b"O\r"]  # No bitrate command is emitted.
+
+        iface.close()
+        await _wait_for(lambda: port.closed)
+
+    asyncio.run(run())
+
+
+def test_webserial_nonstandard_bitrate_passed_through() -> None:
+    async def run() -> None:
+        port = _FakeAsyncSerial()
+        iface = await _start(port, bitrate=123_456)
+
+        assert port.writes == [b"C\r", b"S123456\r", b"O\r"]  # Sent as-is, not rejected.
+
+        iface.close()
+        await _wait_for(lambda: port.closed)
+
+    asyncio.run(run())
+
+
+def test_webserial_purges_stale_input_before_open() -> None:
+    async def run() -> None:
+        # A frame buffered by the adapter under the old config must be discarded by the purge.
+        port = _FakeAsyncSerial([b"T000007FF155\r"])
+        iface = await _start(port)
+
+        port.feed(b"T000001231AA\r")  # Fresh frame, after open.
+        frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
+
+        assert frame.id == 0x123
+        assert frame.data == b"\xaa"
+        iface.close()
+
+    asyncio.run(run())
 
 
 def test_webserial_initialization_nack_closes_interface() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial([_ACK, _NACK])
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = WebSerialSLCANInterface(port, bitrate=1_000_000)
+
+        await _wait_for(lambda: len(port.writes) > 1)  # Close, then the bitrate command.
+        port.feed(_NACK)
 
         await _wait_for(lambda: port.closed)
         assert port.writes == [b"C\r", b"S8\r"]
@@ -119,8 +182,8 @@ async def run() -> None:
 
 def test_enqueue_writes_expected_slcan_bytes() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
         iface.enqueue(0x123, [memoryview(b"\xaa"), memoryview(b"")], Instant.now() + 1.0)
 
         await _wait_for(lambda: len(port.writes) == len(_INIT_1M) + 2)
@@ -134,8 +197,9 @@ async def run() -> None:
 
 def test_receive_returns_timestamped_frame_and_drops_malformed_input() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads(b"bad\rT000001231AA\r"))
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
+        port.feed(b"bad\rT000001231AA\r")
 
         before = Instant.now()
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
@@ -150,8 +214,9 @@ async def run() -> None:
 
 def test_receive_accepts_slcan_optional_suffix() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads(b"T10AE6EFF8000000FF000000A07071Lvendor\r"))
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
+        port.feed(b"T10AE6EFF8000000FF000000A07071Lvendor\r")
 
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
 
@@ -164,9 +229,10 @@ async def run() -> None:
 
 def test_receive_filter_is_noop() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads(b"T000001231AA\rT000004561BB\r"))
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
         iface.filter([Filter(id=0x456, mask=0x1FFFFFFF)])
+        port.feed(b"T000001231AA\rT000004561BB\r")
 
         frame = await asyncio.wait_for(iface.receive(), timeout=1.0)
 
@@ -179,8 +245,8 @@ async def run() -> None:
 
 def test_expired_deadline_is_dropped() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + (-1.0))
         iface.enqueue(0x124, [memoryview(b"\xbb")], Instant.now() + 1.0)
@@ -194,8 +260,8 @@ async def run() -> None:
 
 def test_purge_drops_pending_tx() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 10.0)
         iface.purge()
@@ -210,7 +276,7 @@ async def run() -> None:
 
 def test_close_unblocks_pending_receive_and_operations_raise() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
+        port = _FakeAsyncSerial()
         iface = WebSerialSLCANInterface(port)
         task = asyncio.create_task(iface.receive())
         await asyncio.sleep(0)
@@ -231,8 +297,9 @@ async def run() -> None:
 
 def test_read_failure_closes_interface() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads(OSError("rx failed")))
-        iface = WebSerialSLCANInterface(port)
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
+        port.reads.append(OSError("rx failed"))
 
         with pytest.raises(ClosedError, match="receive failed") as exc_info:
             await asyncio.wait_for(iface.receive(), timeout=1.0)
@@ -248,9 +315,8 @@ async def run() -> None:
 
 def test_write_failure_closes_interface() -> None:
     async def run() -> None:
-        port = _FakeAsyncSerial(_init_reads())
-        iface = WebSerialSLCANInterface(port)
-        await _wait_for(lambda: len(port.writes) == len(_INIT_1M))
+        port = _FakeAsyncSerial()
+        iface = await _start(port)
         port.write_errors.append(OSError("tx failed"))
 
         iface.enqueue(0x123, [memoryview(b"\xaa")], Instant.now() + 1.0)
@@ -265,7 +331,7 @@ async def run() -> None:
 
 def test_enqueue_validation() -> None:
     async def run() -> None:
-        iface = WebSerialSLCANInterface(_FakeAsyncSerial(_init_reads()))
+        iface = WebSerialSLCANInterface(_FakeAsyncSerial())
 
         with pytest.raises(ValueError, match="Invalid CAN identifier"):
             iface.enqueue(-1, [memoryview(b"")], Instant.now())