Add OpenIPC Vectis support (RFC 2217 transport)#64
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
What changed
New transport
defib.transport.rfc2217.Rfc2217Transportwraps pyserial'sserial.serial_for_url("rfc2217://...")backend with threenon-obvious adjustments:
read(size)can return awhole queue chunk that exceeds
size(it appends the chunk andexits the loop because
len(data) >= size). We cap at the caller'ssize and stash the overflow so 1-byte ACK comparisons work.
_port.timeoutmutation per read. pyserial 3.5'srfc2217.Serial.timeoutsetter 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_NODELAYenabled. pyserial's RFC 2217 backend leavesNagle on, which on a high-RTT link batches our small writes for
one RTT before sending. That alone closes the bootrom's
~100 ms0x20-marker /0xAA-ack catch window.The transport also exposes
set_dtr/set_rts/set_baudratethat wrap the corresponding
pyserialproperties — pyserial doesthe actual
SET-CONTROL/SET-BAUDRATEsub-option emission.URL scheme
rfc2217://host:portjoinstcp://,socket://, and/dev/...on the existing
serial_platform.create_transportdispatch. Defaulttcp://is preserved for legacy raw-TCP bridges (older Vectiswithout RFC 2217) and for any non-Vectis serial-over-TCP server.
VectisControllerrewritepower_cycle()now callsset_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+Pbyte if the transport doesn't expose modem-control (raw
tcp://against a pre-RFC-2217 Vectis daemon).
power_off/power_onraise (Vectis is pulse-only);restorerejects Vectis at the CLI to avoid trying to use that flow.
Power-controller factory
defib.power.power_controller_from_env()picks RouterOS or Vectisbased on
DEFIB_POWER_TYPE. CLI's--power-cycleflag flowsthrough 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-ackflooding) instead of the blind
0xAA + HEADblast in_send_frame_for_start(). The blast catches U-Boot's characterecho as a false-positive ACK on healthy boards over RFC 2217;
the marker handshake requires actually seeing the bootrom's
0x20markers as proof of download mode and is strictly morerobust.
drain_until_silent()is skipped on this path so themarker 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-cyclehonoursDEFIB_POWER_TYPE(defaultrouteros,unchanged behaviour). When it's
vectis, the CLI hands the liveRfc2217Transportto the controller viaattach_transport. Boththe standalone
-tand the inetdnowaitVectis topologies areaddressable through the same
rfc2217://host:portURL.Verification
End-to-end against a real hi3516cv300:
rfc2217://localhost) + MikroTik PoEtests/test_transport_rfc2217.py: 26 testscreate_transport_port.timeoutis not mutated perread), partial returns on timeout, raise on no-byte timeout
set_dtr/set_rts/set_baudratedelegate to pyserial attrswrite/flush_input(clears local buffer + pyserial buffer) /bytes_waiting(sums local + pyserial) /close(skips whenalready closed)
binary needed): negotiation, default port settings on open
(8N1 + 115200),
SET-CONTROLvalues 8/9/11/12 on the wire forDTR/RTS,
SET-BAUDRATE, IAC-escaped binary round-trip with all256 byte values,
TCP_NODELAYactually set on the socketcreate()raisesTransportErroron unreachable URLtests/test_power_vectis.py: 11 testsfrom_envmatrix (missing host raises, defaults, custom port)power_off/power_onraisepower_cycleissues exactly the four SET-CONTROLtoggles in the right order; no in-band write
close()does not close the shared transportset_dtr/set_rtscloseis idempotent77 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 windowcan 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-OPTIONsub-option, whichwould close this gap entirely.