Skip to content

Commit 7b93c5d

Browse files
authored
feat(tcp-connector): add AimX TCP transport (#166)
Add aimdb-tcp-connector with length-prefixed TCP framing, Tokio client/server support, Embassy client support and tcp://host:port endpoint support in aimdb-client/CLI. Closes #121
1 parent 7fe5e88 commit 7b93c5d

18 files changed

Lines changed: 1168 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ members = [
1515
"aimdb-websocket-connector",
1616
"aimdb-uds-connector",
1717
"aimdb-serial-connector",
18+
"aimdb-tcp-connector",
1819
"aimdb-ws-protocol",
1920
"aimdb-wasm-adapter",
2021
"tools/aimdb-cli",

Makefile

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ build:
102102
cargo build --package aimdb-uds-connector
103103
@printf "$(YELLOW) → Building serial connector (tokio)$(NC)\n"
104104
cargo build --package aimdb-serial-connector --no-default-features --features "tokio-runtime"
105+
@printf "$(YELLOW) → Building TCP connector (tokio)$(NC)\n"
106+
cargo build --package aimdb-tcp-connector --no-default-features --features "tokio-runtime"
105107
@printf "$(YELLOW) → Building WASM adapter$(NC)\n"
106108
cargo build --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime"
107109
@printf "$(YELLOW) → Building benchmarking infrastructure (host-only, incl. benches)$(NC)\n"
@@ -133,6 +135,8 @@ test:
133135
cargo test --package aimdb-client
134136
@printf "$(YELLOW) → Testing aimdb-client (endpoint resolver, serial transport arm)$(NC)\n"
135137
cargo test --package aimdb-client --no-default-features --features "transport-serial"
138+
@printf "$(YELLOW) → Testing aimdb-client (endpoint resolver, TCP transport arm)$(NC)\n"
139+
cargo test --package aimdb-client --no-default-features --features "transport-tcp"
136140
@printf "$(YELLOW) → Testing tokio adapter$(NC)\n"
137141
cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing"
138142
@printf "$(YELLOW) → Testing tokio adapter (with observability)$(NC)\n"
@@ -169,10 +173,12 @@ test:
169173
cargo test --package aimdb-serial-connector --no-default-features --features "_test-tokio"
170174
@printf "$(YELLOW) → Testing serial connector (embassy: COBS framing + client-engine smoke on the EmbassyAdapter clock)$(NC)\n"
171175
cargo test --package aimdb-serial-connector --no-default-features --features "embassy-runtime"
176+
@printf "$(YELLOW) → Testing TCP connector (tokio: length-prefix framing + AimX loopback)$(NC)\n"
177+
cargo test --package aimdb-tcp-connector --no-default-features --features "_test-tokio"
172178

173179
fmt:
174180
@printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n"
175-
@for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-ws-protocol aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest-async aimdb-bench; do \
181+
@for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-ws-protocol aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest-async aimdb-bench; do \
176182
printf "$(YELLOW) → Formatting $$pkg$(NC)\n"; \
177183
cargo fmt -p $$pkg 2>/dev/null || true; \
178184
done
@@ -181,7 +187,7 @@ fmt:
181187
fmt-check:
182188
@printf "$(GREEN)Checking code formatting (workspace members only)...$(NC)\n"
183189
@FAILED=0; \
184-
for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-ws-protocol aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo tokio-knx-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest-async aimdb-bench; do \
190+
for pkg in aimdb-derive aimdb-data-contracts aimdb-core aimdb-client aimdb-embassy-adapter aimdb-tokio-adapter aimdb-wasm-adapter aimdb-sync aimdb-persistence aimdb-persistence-sqlite aimdb-mqtt-connector aimdb-knx-connector aimdb-ws-protocol aimdb-websocket-connector aimdb-uds-connector aimdb-serial-connector aimdb-tcp-connector aimdb-codegen aimdb-cli aimdb-mcp sync-api-demo tokio-mqtt-connector-demo embassy-mqtt-connector-demo embassy-knx-connector-demo embassy-serial-connector-demo embassy-bench-stm32h5 weather-mesh-common weather-hub weather-station-alpha weather-station-beta hello-mailbox hello-mailbox-async hello-single-latest-async aimdb-bench; do \
185191
printf "$(YELLOW) → Checking $$pkg$(NC)\n"; \
186192
if ! cargo fmt -p $$pkg -- --check 2>&1; then \
187193
printf "$(RED)❌ Formatting check failed for $$pkg$(NC)\n"; \
@@ -222,12 +228,16 @@ clippy:
222228
cargo clippy --package aimdb-client --all-targets -- -D warnings
223229
@printf "$(YELLOW) → Clippy on client library (serial transport arm)$(NC)\n"
224230
cargo clippy --package aimdb-client --no-default-features --features "transport-serial" --all-targets -- -D warnings
231+
@printf "$(YELLOW) → Clippy on client library (TCP transport arm)$(NC)\n"
232+
cargo clippy --package aimdb-client --no-default-features --features "transport-tcp" --all-targets -- -D warnings
225233
@printf "$(YELLOW) → Clippy on codegen library$(NC)\n"
226234
cargo clippy --package aimdb-codegen --all-targets -- -D warnings
227235
@printf "$(YELLOW) → Clippy on CLI tools$(NC)\n"
228236
cargo clippy --package aimdb-cli --all-targets -- -D warnings
229237
@printf "$(YELLOW) → Clippy on CLI tools (serial transport)$(NC)\n"
230238
cargo clippy --package aimdb-cli --features "transport-serial" --all-targets -- -D warnings
239+
@printf "$(YELLOW) → Clippy on CLI tools (TCP transport)$(NC)\n"
240+
cargo clippy --package aimdb-cli --features "transport-tcp" --all-targets -- -D warnings
231241
@printf "$(YELLOW) → Clippy on MCP server$(NC)\n"
232242
cargo clippy --package aimdb-mcp --all-targets -- -D warnings
233243
@printf "$(YELLOW) → Clippy on MCP server (serial transport)$(NC)\n"
@@ -256,6 +266,12 @@ clippy:
256266
cargo clippy --package aimdb-serial-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" -- -D warnings
257267
@printf "$(YELLOW) → Clippy on serial connector (embassy + defmt)$(NC)\n"
258268
cargo clippy --package aimdb-serial-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings
269+
@printf "$(YELLOW) → Clippy on TCP connector (tokio)$(NC)\n"
270+
cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-tokio" --all-targets -- -D warnings
271+
@printf "$(YELLOW) → Clippy on TCP connector (embassy)$(NC)\n"
272+
cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" -- -D warnings
273+
@printf "$(YELLOW) → Clippy on TCP connector (embassy + defmt)$(NC)\n"
274+
cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" -- -D warnings
259275
@printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n"
260276
cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings
261277
@printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n"
@@ -348,6 +364,10 @@ test-embedded:
348364
cargo check --package aimdb-serial-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime"
349365
@printf "$(YELLOW) → Checking aimdb-serial-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n"
350366
cargo check --package aimdb-serial-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt"
367+
@printf "$(YELLOW) → Checking aimdb-tcp-connector (Embassy TCP client) on thumbv7em-none-eabihf target$(NC)\n"
368+
cargo check --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime"
369+
@printf "$(YELLOW) → Checking aimdb-tcp-connector (Embassy TCP client + defmt) on thumbv7em-none-eabihf target$(NC)\n"
370+
cargo check --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt"
351371

352372
## Example projects
353373
examples:

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ See the [MCP server docs](tools/aimdb-mcp/) for Claude Desktop and other editors
207207
| **MQTT**`aimdb-mqtt-connector` | ✅ Ready | std, no_std |
208208
| **KNX**`aimdb-knx-connector` | ✅ Ready | std, no_std |
209209
| **WebSocket**`aimdb-websocket-connector` | ✅ Ready | std, wasm |
210+
| **TCP**`aimdb-tcp-connector` | ✅ Ready | std, no_std client |
210211
| **Kafka** | 📋 Planned | std |
211212
| **Modbus** | 📋 Planned | std, no_std |
212213

aimdb-client/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ observability = ["aimdb-core/observability"]
1818
# compiled in is rejected at resolve time.
1919
transport-uds = ["dep:aimdb-uds-connector"]
2020
transport-serial = ["dep:aimdb-serial-connector"]
21+
transport-tcp = ["dep:aimdb-tcp-connector"]
2122

2223
[dependencies]
2324
# Core dependencies - protocol types from aimdb-core. `connector-session`
@@ -34,6 +35,9 @@ aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector", opti
3435
aimdb-serial-connector = { version = "0.1.0", path = "../aimdb-serial-connector", default-features = false, features = [
3536
"tokio-runtime",
3637
], optional = true }
38+
aimdb-tcp-connector = { version = "0.1.0", path = "../aimdb-tcp-connector", default-features = false, features = [
39+
"tokio-runtime",
40+
], optional = true }
3741

3842
# Serialization
3943
serde = { version = "1", features = ["derive"] }

aimdb-client/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Internal client library for the AimX remote access protocol (v2 NDJSON wire).
88
implementation for the AimX remote access protocol. It enables programmatic
99
connections to running AimDB instances over a transport picked at runtime via
1010
a `scheme://` endpoint URL — Unix domain sockets (`unix://PATH` / `uds://PATH`,
11-
or a bare path) and serial (`serial://DEVICE?baud=N`).
11+
or a bare path), serial (`serial://DEVICE?baud=N`), and TCP
12+
(`tcp://HOST:PORT`, behind the `transport-tcp` feature).
1213

1314
**This library is used by:**
1415
- `tools/aimdb-cli` - Command-line interface for AimDB
@@ -71,6 +72,9 @@ See these tools for real-world usage patterns.
7172
# Run tests
7273
cargo test -p aimdb-client
7374

75+
# Run TCP endpoint resolver tests
76+
cargo test -p aimdb-client --no-default-features --features transport-tcp
77+
7478
# Start test server
7579
cargo run --example remote-access-demo
7680
```

aimdb-client/src/endpoint.rs

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@
55
//! Two layers, deliberately split so the grammar is testable without any
66
//! transport compiled in:
77
//! - [`parse_endpoint`] — **pure, feature-independent**. Recognizes the scheme
8-
//! grammar (`unix://` / `uds://` / `serial://`, plus a bare path as `unix://`
9-
//! shorthand) into a [`ParsedEndpoint`]. An unknown scheme is rejected here.
8+
//! grammar (`unix://` / `uds://` / `serial://` / `tcp://host:port`, plus a
9+
//! bare path as `unix://` shorthand) into a [`ParsedEndpoint`]. An unknown
10+
//! scheme is rejected here.
1011
//! - [`dial`] — builds the concrete [`Dialer`] for a parsed endpoint, under the
1112
//! matching `transport-*` feature. A scheme whose transport isn't compiled into
1213
//! this binary is rejected here (distinct from "unknown scheme").
1314
//!
14-
//! TCP is intentionally absent for now (tracked separately); adding it is a new
15-
//! [`Scheme`] arm plus a `dial` branch.
16-
1715
use aimdb_core::session::Dialer;
1816

1917
use crate::error::{ClientError, ClientResult};
@@ -23,7 +21,7 @@ pub const DEFAULT_SERIAL_BAUD: u32 = 115_200;
2321

2422
/// Schemes the resolver's *grammar* understands, independent of which transports
2523
/// are compiled in. Used to phrase the "unknown scheme" error.
26-
const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial"];
24+
const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial", "tcp"];
2725

2826
/// The transport family an endpoint names. Always compiled (it is grammar, not a
2927
/// capability) — whether a given variant can actually be dialed depends on the
@@ -34,6 +32,8 @@ pub enum Scheme {
3432
Unix,
3533
/// A serial/UART device (`serial://`).
3634
Serial,
35+
/// A TCP endpoint (`tcp://host:port`).
36+
Tcp,
3737
}
3838

3939
/// A parsed endpoint: the transport family plus its target and any transport
@@ -42,7 +42,7 @@ pub enum Scheme {
4242
pub struct ParsedEndpoint {
4343
/// Which transport family this endpoint names.
4444
pub scheme: Scheme,
45-
/// The transport target — a socket path (`Unix`) or device path (`Serial`).
45+
/// The transport target — a socket path, serial device path, or host:port.
4646
pub target: String,
4747
/// Serial baud from `?baud=N`, if given (`Serial` only; [`dial`] defaults it
4848
/// to [`DEFAULT_SERIAL_BAUD`]).
@@ -54,7 +54,8 @@ pub struct ParsedEndpoint {
5454
/// - `unix://PATH` / `uds://PATH` → [`Scheme::Unix`].
5555
/// - a bare path (no `scheme://`) → [`Scheme::Unix`] (the shorthand).
5656
/// - `serial://PATH` (optionally `?baud=N`) → [`Scheme::Serial`].
57-
/// - anything else (e.g. `tcp://…`) → [`ClientError::UnsupportedEndpoint`].
57+
/// - `tcp://HOST:PORT` → [`Scheme::Tcp`].
58+
/// - anything else → [`ClientError::UnsupportedEndpoint`].
5859
pub fn parse_endpoint(endpoint: &str) -> ClientResult<ParsedEndpoint> {
5960
let endpoint = endpoint.trim();
6061
if endpoint.is_empty() {
@@ -91,6 +92,14 @@ pub fn parse_endpoint(endpoint: &str) -> ClientResult<ParsedEndpoint> {
9192
baud,
9293
})
9394
}
95+
"tcp" => {
96+
require_tcp_target(endpoint, rest)?;
97+
Ok(ParsedEndpoint {
98+
scheme: Scheme::Tcp,
99+
target: rest.to_string(),
100+
baud: None,
101+
})
102+
}
94103
other => Err(ClientError::unsupported_endpoint(
95104
endpoint,
96105
format!(
@@ -132,6 +141,16 @@ pub fn dial(endpoint: &str) -> ClientResult<Box<dyn Dialer>> {
132141
Err(not_built_in(endpoint, "serial", "transport-serial"))
133142
}
134143
}
144+
Scheme::Tcp => {
145+
#[cfg(feature = "transport-tcp")]
146+
{
147+
Ok(Box::new(aimdb_tcp_connector::TcpDialer::new(parsed.target)))
148+
}
149+
#[cfg(not(feature = "transport-tcp"))]
150+
{
151+
Err(not_built_in(endpoint, "tcp", "transport-tcp"))
152+
}
153+
}
135154
}
136155
}
137156

@@ -147,6 +166,34 @@ fn require_nonempty(endpoint: &str, target: &str) -> ClientResult<()> {
147166
}
148167
}
149168

169+
/// Validate `tcp://host:port`.
170+
fn require_tcp_target(endpoint: &str, target: &str) -> ClientResult<()> {
171+
require_nonempty(endpoint, target)?;
172+
173+
let Some((host, port)) = target.rsplit_once(':') else {
174+
return Err(ClientError::unsupported_endpoint(
175+
endpoint,
176+
"missing TCP port",
177+
));
178+
};
179+
if host.is_empty() {
180+
return Err(ClientError::unsupported_endpoint(
181+
endpoint,
182+
"missing TCP host",
183+
));
184+
}
185+
if port.is_empty() {
186+
return Err(ClientError::unsupported_endpoint(
187+
endpoint,
188+
"missing TCP port",
189+
));
190+
}
191+
port.parse::<u16>().map_err(|_| {
192+
ClientError::unsupported_endpoint(endpoint, format!("invalid TCP port {port:?}"))
193+
})?;
194+
Ok(())
195+
}
196+
150197
/// Pull `baud` out of a `serial://` query string (`baud=N[&k=v…]`).
151198
fn parse_baud(endpoint: &str, query: &str) -> ClientResult<Option<u32>> {
152199
for pair in query.split('&').filter(|p| !p.is_empty()) {
@@ -162,7 +209,11 @@ fn parse_baud(endpoint: &str, query: &str) -> ClientResult<Option<u32>> {
162209
}
163210

164211
/// A recognized scheme whose transport feature isn't compiled in.
165-
#[cfg(any(not(feature = "transport-uds"), not(feature = "transport-serial")))]
212+
#[cfg(any(
213+
not(feature = "transport-uds"),
214+
not(feature = "transport-serial"),
215+
not(feature = "transport-tcp")
216+
))]
166217
fn not_built_in(endpoint: &str, scheme: &str, feature: &str) -> ClientError {
167218
ClientError::unsupported_endpoint(
168219
endpoint,
@@ -208,13 +259,24 @@ mod tests {
208259
assert_eq!(p.baud, Some(230400));
209260
}
210261

262+
#[test]
263+
fn tcp_scheme_parses_host_and_port() {
264+
let p = parse_endpoint("tcp://127.0.0.1:7001").expect("parse");
265+
assert_eq!(p.scheme, Scheme::Tcp);
266+
assert_eq!(p.target, "127.0.0.1:7001");
267+
268+
let p = parse_endpoint("tcp://localhost:7001").expect("parse");
269+
assert_eq!(p.scheme, Scheme::Tcp);
270+
assert_eq!(p.target, "localhost:7001");
271+
}
272+
211273
#[test]
212274
fn malformed_endpoints_are_rejected() {
213-
// Unknown scheme.
214-
assert!(matches!(
215-
parse_endpoint("tcp://host:1234"),
216-
Err(ClientError::UnsupportedEndpoint { .. })
217-
));
275+
// Malformed TCP.
276+
assert!(parse_endpoint("tcp://").is_err());
277+
assert!(parse_endpoint("tcp://host").is_err());
278+
assert!(parse_endpoint("tcp://:1234").is_err());
279+
assert!(parse_endpoint("tcp://host:fast").is_err());
218280
// Empty + empty target.
219281
assert!(parse_endpoint("").is_err());
220282
assert!(parse_endpoint("unix://").is_err());
@@ -225,7 +287,7 @@ mod tests {
225287
#[test]
226288
fn dial_rejects_unknown_scheme() {
227289
assert!(matches!(
228-
dial("tcp://host:1234"),
290+
dial("http://host:1234"),
229291
Err(ClientError::UnsupportedEndpoint { .. })
230292
));
231293
}
@@ -251,4 +313,19 @@ mod tests {
251313
fn dial_builds_a_serial_dialer() {
252314
assert!(dial("serial:///dev/ttyACM0?baud=115200").is_ok());
253315
}
316+
317+
#[cfg(not(feature = "transport-tcp"))]
318+
#[test]
319+
fn dial_rejects_tcp_when_not_built_in() {
320+
assert!(matches!(
321+
dial("tcp://127.0.0.1:7001"),
322+
Err(ClientError::UnsupportedEndpoint { .. })
323+
));
324+
}
325+
326+
#[cfg(feature = "transport-tcp")]
327+
#[test]
328+
fn dial_builds_a_tcp_dialer() {
329+
assert!(dial("tcp://127.0.0.1:7001").is_ok());
330+
}
254331
}

0 commit comments

Comments
 (0)