Skip to content

Add OpenIPC Vectis support (RFC 2217 transport)#64

Merged
widgetii merged 3 commits intomasterfrom
vectis-rfc2217
May 1, 2026
Merged

Add OpenIPC Vectis support (RFC 2217 transport)#64
widgetii merged 3 commits intomasterfrom
vectis-rfc2217

Conversation

@widgetii
Copy link
Copy Markdown
Member

@widgetii widgetii commented May 1, 2026

Why

Vectis is a USB/Ethernet UART
bridge that exposes the camera's UART over TCP and gates camera
power via the bridge's RTS/DTR lines. Vectis ≥ 1.2.0 speaks
RFC 2217 (Telnet COM Port Control Option), so the data
path is binary-safe and modem-control lines (RTS/DTR/baud) are
commanded out-of-band as standard sub-options instead of in-band
magic bytes.

This PR adds defib-side support for that bridge end-to-end so a user
can drive recovery on a Vectis-attached camera with the same
ergonomics as a local serial port + RouterOS PoE:

export DEFIB_POWER_TYPE=vectis
export DEFIB_VECTIS_HOST=172.17.32.17
defib burn -c hi3516cv300 -p rfc2217://172.17.32.17:35240 --power-cycle -b

What changed

New transport

defib.transport.rfc2217.Rfc2217Transport wraps pyserial's
serial.serial_for_url("rfc2217://...") backend with three
non-obvious adjustments:

  • Overflow buffer. pyserial's RFC 2217 read(size) can return a
    whole queue chunk that exceeds size (it appends the chunk and
    exits the loop because len(data) >= size). We cap at the caller's
    size and stash the overflow so 1-byte ACK comparisons work.
  • No _port.timeout mutation per read. pyserial 3.5's
    rfc2217.Serial.timeout setter calls _reconfigure_port(),
    which re-renegotiates every port parameter over the network and
    costs ~400 ms per call on a real link. We set a small fixed
    pyserial quantum once at open and enforce caller deadlines in
    Python.
  • TCP_NODELAY enabled. pyserial's RFC 2217 backend leaves
    Nagle on, which on a high-RTT link batches our small writes for
    one RTT before sending. That alone closes the bootrom's
    ~100 ms 0x20-marker / 0xAA-ack catch window.

The transport also exposes set_dtr / set_rts / set_baudrate
that wrap the corresponding pyserial properties — pyserial does
the actual SET-CONTROL / SET-BAUDRATE sub-option emission.

URL scheme

rfc2217://host:port joins tcp://, socket://, and /dev/...
on the existing serial_platform.create_transport dispatch. Default
tcp:// is preserved for legacy raw-TCP bridges (older Vectis
without RFC 2217) and for any non-Vectis serial-over-TCP server.

VectisController rewrite

power_cycle() now calls
set_dtr(False); set_rts(False); sleep(pulse_seconds); set_rts(True); set_dtr(True) — proper out-of-band SET-CONTROL,
no in-band magic byte. Falls back to writing a single Ctrl+P
byte if the transport doesn't expose modem-control (raw tcp://
against a pre-RFC-2217 Vectis daemon).

power_off/power_on raise (Vectis is pulse-only); restore
rejects Vectis at the CLI to avoid trying to use that flow.

Power-controller factory

defib.power.power_controller_from_env() picks RouterOS or Vectis
based on DEFIB_POWER_TYPE. CLI's --power-cycle flag flows
through this factory in burn, install, and (with rejection)
restore.

Marker-based handshake for frame-blast chips

recovery/session.py: when a frame-blast chip (e.g. hi3516cv300)
is recovered with power control, route through the existing
marker-detecting protocol.handshake() (with continuous-ack
flooding) instead of the blind 0xAA + HEAD blast in
_send_frame_for_start(). The blast catches U-Boot's character
echo as a false-positive ACK on healthy boards over RFC 2217;
the marker handshake requires actually seeing the bootrom's
0x20 markers as proof of download mode and is strictly more
robust. drain_until_silent() is skipped on this path so the
marker window isn't lost. The flood also writes a 64-byte burst
per iteration so a high-RTT link still saturates the UART line.

CLI

--power-cycle honours DEFIB_POWER_TYPE (default routeros,
unchanged behaviour). When it's vectis, the CLI hands the live
Rfc2217Transport to the controller via attach_transport. Both
the standalone -t and the inetd nowait Vectis topologies are
addressable through the same rfc2217://host:port URL.

Verification

End-to-end against a real hi3516cv300:

Path Result
Local serial + MikroTik PoE ✓ 21 s burn
Local Vectis (rfc2217://localhost) + MikroTik PoE ✓ 37 s burn
Remote Vectis (40 ms RTT) ✗ bootrom catch racing the link RTT — see "Known limitation" below

tests/test_transport_rfc2217.py: 26 tests

  • URL-scheme dispatch through create_transport
  • Overflow buffer, unread, exact-size paths
  • Timeout enforcement (verifies _port.timeout is not mutated per
    read), partial returns on timeout, raise on no-byte timeout
  • set_dtr / set_rts / set_baudrate delegate to pyserial attrs
  • write / flush_input (clears local buffer + pyserial buffer) /
    bytes_waiting (sums local + pyserial) / close (skips when
    already closed)
  • End-to-end against an in-process fake RFC 2217 server (no external
    binary needed): negotiation, default port settings on open
    (8N1 + 115200), SET-CONTROL values 8/9/11/12 on the wire for
    DTR/RTS, SET-BAUDRATE, IAC-escaped binary round-trip with all
    256 byte values, TCP_NODELAY actually set on the socket
  • create() raises TransportError on unreachable URL

tests/test_power_vectis.py: 11 tests

  • from_env matrix (missing host raises, defaults, custom port)
  • power_off / power_on raise
  • Shared-transport power_cycle issues exactly the four SET-CONTROL
    toggles in the right order; no in-band write
  • close() does not close the shared transport
  • Pulse actually sleeps (timing assertion)
  • Legacy fallback when transport lacks set_dtr / set_rts
  • Standalone close is idempotent

77 tests pass on the affected modules. ruff clean. mypy clean.

Known limitation

Tight-loop bootrom catching needs a low-RTT link to Vectis. Over a
high-RTT WAN link the bootrom's 0x20-marker / 0xAA-ack window
can close before the round-trip completes; running Vectis on the
same host as defib (or close to it on a LAN) is recommended.

A follow-up in upstream Vectis can move the bootrom-catch loop
server-side via a dedicated COM-PORT-OPTION sub-option, which
would close this gap entirely.

widgetii added 3 commits May 1, 2026 17:55
Vectis is a USB/Ethernet UART bridge that exposes the camera's UART
over TCP and gates camera power via the bridge's RTS/DTR lines.
Vectis ≥ 1.2.0 speaks RFC 2217 (Telnet COM Port Control Option), so
the data path is binary-safe and modem-control lines (RTS/DTR/baud)
are commanded out-of-band as standard sub-options instead of in-band
magic bytes.

This change adds defib-side support for that bridge end-to-end:

- New `defib.transport.rfc2217.Rfc2217Transport`: wraps pyserial's
  `serial.serial_for_url("rfc2217://...")` backend, adds an overflow
  buffer (pyserial's RFC 2217 read can return larger chunks than
  asked), enforces caller timeouts in Python without mutating
  `_port.timeout` (which would re-renegotiate every port parameter
  on each call), and exposes `set_dtr` / `set_rts` / `set_baudrate`
  for SET-CONTROL / SET-BAUDRATE sub-options.  Also enables
  `TCP_NODELAY` so a tight handshake loop doesn't get held by Nagle
  for one RTT per write.
- `rfc2217://host:port` URL scheme on `serial_platform.create_transport`,
  alongside the existing `tcp://` and `socket://` schemes.
- `defib.power.VectisController` rewritten to drive RTS/DTR via
  `transport.set_dtr` / `transport.set_rts` (RFC 2217 SET-CONTROL
  values 8/9 + 11/12).  Falls back to writing a single Ctrl+P byte
  when the transport doesn't expose modem-control (raw `tcp://`
  against pre-RFC-2217 Vectis).
- `defib.power.power_controller_from_env`: factory dispatcher on
  `DEFIB_POWER_TYPE` (`routeros` default, `vectis` new).
- Marker-based handshake for frame-blast chips when power-controlled
  (`recovery/session.py`): hi3516cv300 historically went through a
  blind 0xAA+HEAD blast, which on a healthy camera over RFC 2217
  catches U-Boot's character echo as a false-positive ACK.  The
  marker-based handshake (with continuous-ack flooding) is strictly
  more robust because it requires actually seeing the bootrom's
  0x20 markers as proof of download mode.  Drain-until-silent is
  skipped on this path so the marker window isn't lost.
- Handshake flood writes 64-byte bursts so a high-RTT link still
  saturates the UART line through the bootrom catch window.
- CLI integration: `--power-cycle` honours `DEFIB_POWER_TYPE`; the
  CLI hands the live RFC 2217 transport to a Vectis controller via
  `attach_transport`.  Restore explicitly rejects Vectis (its flow
  needs independent power_off/power_on which Vectis can't do).

Verified end-to-end on hi3516cv300:
- Local serial + MikroTik PoE: full burn in 21 s.
- Local Vectis (rfc2217://localhost) + MikroTik PoE: full burn in
  37 s, exercising the same code path used for remote Vectis.

Tests:
- `tests/test_transport_rfc2217.py`: 26 tests covering URL dispatch,
  overflow buffer, timeout enforcement, modem-control delegation,
  write/flush/close, plus end-to-end against an in-process fake
  RFC 2217 server fixture (negotiation, SET-CONTROL/SET-BAUDRATE
  sub-options on the wire, IAC-escaped binary round-trip, TCP_NODELAY
  on the socket).
- `tests/test_power_vectis.py`: 11 tests covering from_env, off/on
  raise, shared-transport SET-CONTROL sequence, legacy fallback for
  transports without modem-control, standalone close idempotency.

Known limitation: tight-loop bootrom catching needs a low-RTT link
to Vectis.  Over a high-RTT WAN link the bootrom's 0x20-marker /
0xAA-ack window can close before the round-trip completes; running
Vectis on the same host as defib (or close to it on a LAN) is
recommended.  A follow-up in upstream Vectis can move the bootrom
catch loop server-side via a dedicated COM-PORT-OPTION sub-option.
The marker-handshake refactor in this PR moved frame-blast chips
(hi3516cv300, hi3516av200) onto the marker-detecting
``protocol.handshake()`` path when power-controlled, replacing the
old "Frame-blast (deferred)" no-op.  Three existing tests in
``test_handshake_resilience.py`` exercise that exact path with a
``MockTransport`` that never delivers ``0x20`` markers, so the
handshake now loops forever waiting for them.

Mirror what ``test_session_no_retry_without_power_control`` already
does and stub out ``HiSiliconStandard.handshake`` in the affected
tests.  These tests are about session-orchestration semantics
(retry, max-attempts, post-DDR bail-out) — not the protocol — so
short-circuiting the handshake is the right move.

Affected tests:
- ``test_session_retries_handshake_on_transient_failure``
- ``test_session_does_not_retry_post_ddr_failures``
- ``test_session_max_attempts_respected``

Locally: full ``test_handshake_resilience.py`` passes in 1 s
(was hanging indefinitely under CI on every Python/OS combination).
macOS's ``getsockopt(IPPROTO_TCP, TCP_NODELAY)`` returns ``4`` (the
C ``int`` byte width) rather than Linux's ``1`` when the option is
enabled.  The test was strict-equality-checking against ``1`` and
failed on the macos-latest CI matrix while passing on
ubuntu-latest and windows-latest.  Truthiness check is what the
contract actually specifies.
@widgetii widgetii merged commit 94d31f6 into master May 1, 2026
13 checks passed
@widgetii widgetii deleted the vectis-rfc2217 branch May 1, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant