feat: direct TCP Winlink CMS gateway (Phase 5.5)#104
Merged
Conversation
New openpulse-gateway binary connects directly to cms.winlink.org:8772, sends outbound messages via B2F ISS protocol, then receives pending messages via B2F IRS protocol on the same TCP connection.
There was a problem hiding this comment.
Pull request overview
Adds a new openpulse-gateway binary that uses the existing B2F/DataPort pieces to talk directly to a Winlink CMS over TCP, wires it into the workspace, and documents Phase 5.5 as complete. Review found blocking correctness issues in the new gateway flow.
Changes:
- add
crates/openpulse-gatewaywith CLI, TCP exchange logic, and a mock-CMS round-trip test - register the new gateway crate in the workspace
- update
CLAUDE.mdto mark the direct TCP CMS gateway phase as done
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| crates/openpulse-gateway/src/main.rs | New gateway CLI, session handling, TCP exchange logic, and unit test. |
| crates/openpulse-gateway/Cargo.toml | Defines the new binary crate and its dependencies. |
| CLAUDE.md | Marks Phase 5.5 as completed and summarizes the new gateway. |
| Cargo.toml | Adds the gateway crate to the workspace members and workspace dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+149
to
+150
| for (h, b) in messages { | ||
| session.queue_message(h, b)?; |
Comment on lines
+165
to
+168
| tracing::debug!("← {}", line.trim()); | ||
| session.handle_line(&line)?; | ||
|
|
||
| for blob in session.drain_pending_data() { |
| pub(crate) fn irs_receive(data: &mut DataPort) -> anyhow::Result<Vec<Vec<u8>>> { | ||
| let mut session = B2fSession::new(SessionRole::Irs); | ||
|
|
||
| while let Ok(frame) = data.recv_frame() { |
Comment on lines
+225
to
+233
|
|
||
| tracing_subscriber::fmt() | ||
| .with_env_filter( | ||
| tracing_subscriber::EnvFilter::try_from_default_env() | ||
| .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), | ||
| ) | ||
| .init(); | ||
|
|
||
| let cfg = openpulse_config::load()?; |
Comment on lines
+181
to
+184
| pub(crate) fn irs_receive(data: &mut DataPort) -> anyhow::Result<Vec<Vec<u8>>> { | ||
| let mut session = B2fSession::new(SessionRole::Irs); | ||
|
|
||
| while let Ok(frame) = data.recv_frame() { |
Comment on lines
+262
to
+265
| let header = build_header(&callsign, &to, &subject, body.len() as u32); | ||
| tracing::info!("Connecting to {}:{} as {}", cli.host, cli.port, callsign); | ||
|
|
||
| let received = connect_and_exchange(&cli.host, cli.port, vec![(header, body)])?; |
Comment on lines
+345
to
+352
| **Phase 5.5 — Direct TCP Winlink CMS gateway** ✅ Done | ||
| - `crates/openpulse-gateway/`: new binary crate (`openpulse-gateway`) | ||
| - Phase 1 (ISS): connects to `cms.winlink.org:8772`, reads CMS banner, sends FC+FF proposals, reads FS, sends compressed blobs | ||
| - Phase 2 (IRS): same TCP connection, fresh `B2fSession(Irs)`, reads CMS FC+FF proposals, sends FS, reads and decompresses reply blobs | ||
| - `DataPort` wraps `TcpStream` directly — Winlink CMS TCP uses identical u16-BE framing as B2F driver | ||
| - CLI: `openpulse-gateway [--host] [--port] [--callsign] send --to <CALL> [--subject] [--message | stdin]` | ||
| - Callsign read from `~/.config/openpulse/config.toml`; `--callsign` overrides; bails on default `N0CALL` | ||
| - `gateway_round_trip` unit test: mock CMS TCP server validates full ISS+IRS exchange without network access |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+262
to
+265
| let header = build_header(&callsign, &to, &subject, body.len() as u32); | ||
| tracing::info!("Connecting to {}:{} as {}", cli.host, cli.port, callsign); | ||
|
|
||
| let received = connect_and_exchange(&cli.host, cli.port, vec![(header, body)])?; |
Comment on lines
+200
to
+201
| let blob = data.recv_frame().context("reading CMS message blob")?; | ||
| out.push(session.receive_data(blob)?); |
Comment on lines
+225
to
+233
|
|
||
| tracing_subscriber::fmt() | ||
| .with_env_filter( | ||
| tracing_subscriber::EnvFilter::try_from_default_env() | ||
| .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), | ||
| ) | ||
| .init(); | ||
|
|
||
| let cfg = openpulse_config::load()?; |
Comment on lines
+163
to
+171
| let frame = data.recv_frame().context("reading CMS FS")?; | ||
| let line = String::from_utf8_lossy(&frame).into_owned(); | ||
| tracing::debug!("← {}", line.trim()); | ||
| session.handle_line(&line)?; | ||
|
|
||
| for blob in session.drain_pending_data() { | ||
| tracing::debug!("→ [blob {} B]", blob.len()); | ||
| data.send_frame(&blob)?; | ||
| } |
Comment on lines
+184
to
+188
| while let Ok(frame) = data.recv_frame() { | ||
| let line = String::from_utf8_lossy(&frame).into_owned(); | ||
| tracing::debug!("← {}", line.trim()); | ||
| let responses = session.handle_line(&line)?; | ||
| for resp in &responses { |
…O errors - session.rs: queue_message now prepends encoded header bytes before gzip, so To/From/Subject reach the CMS - b2f-driver lib.rs: DecodedMessage gains a `header` field; run_irs splits decompressed blobs on \r\n\r\n using new split_message helper - gateway main.rs: build_header sets body field correctly; iss_send bails if CMS rejects all proposals; irs_receive propagates non-EOF I/O errors instead of treating timeout as empty mailbox; tracing init moved after config load so logging.level setting is respected - Updated b2f_integration, driver_integration, and gateway_round_trip tests to assert on both header metadata and body bytes
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.
Summary
openpulse-gatewaybinary crate connecting directly tocms.winlink.org:8772over TCPB2fSession(Irs)to receive pending messages from the CMSopenpulse-gateway [--host] [--port] [--callsign] send --to <CALL> [--subject] [--message | stdin]station.callsignfrom~/.config/openpulse/config.toml;--callsignoverridesTest plan
gateway_round_tripunit test: loopback TCP pair, mock CMS server in a thread validates full ISS+IRS exchange (no network access)cargo test -p openpulse-gateway --no-default-featurescargo test --workspace --no-default-featurescargo clippy --workspace --no-default-features -- -D warningscargo fmt --all -- --checkCloses roadmap task: Phase 5.5 — Direct TCP Winlink CMS gateway