diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 7e69597..9f70ffd 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -12,8 +12,8 @@ "--manifest-path", "${workspaceFolder}/tools/aimdb-mcp/Cargo.toml", "--", - "--socket", - "/tmp/aimdb-demo.sock" + "--connect", + "unix:///tmp/aimdb-demo.sock" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 844cf0a..50669c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Embassy buffer + join-queue tests now run in CI (Issue #85).** The join-queue tests previously sat behind `embassy-runtime`, which pulls `embassy-executor`'s cortex-m assembly and can't compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions were never caught. The `join_queue` module is now gated on `embassy-sync`, and `make test` runs the embassy adapter's unit tests + doctests on the host (no executor). Also adds `EmbassyBuffer::peek()` and fixes a stale `EmbassyBuffer` doc example. ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) - **`no_std` AimX server — a board can serve a host, not just dial one (Issue #120, follow-up to #39).** Cross-cutting de-std of `aimdb-core`'s central record API behind a new **`remote-access`** feature (`= ["json-serialize", "thiserror"]`, transitively enabled by `std`): the type-erased `AnyRecord` JSON + metadata methods, the `AimDb` JSON read/write/subscribe API, the `remote` module (config / protocol / security / error), and the AimX server dispatch (`AimxDispatch`/`AimxSession`) now all compile on `no_std + alloc` — swapping `std::collections` → `hashbrown`, `std::sync::Arc` → `alloc::sync::Arc`, and `thiserror` to `default-features = false`. Adds a runtime-neutral wall clock, `TimeOps::unix_time()`, implemented from the OS clock on Tokio and from an `EmbassyAdapter::set_unix_time(...)` anchor on Embassy. Verified by a new `thumbv7em-none-eabihf` dispatch cross-check in the Makefile. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) - **`aimdb-serial-connector` — COBS-framed serial/UART transport, the headline embedded scenario (Issue #122, follow-up to #39 / #120).** New crate: a sensor MCU dials a gateway over UART (and, on the `no_std` `AimxDispatch` from #120, an MCU *serves* a host over UART). Contributes only the `Dialer`/`Listener`/`Connection` triple + `SerialClient`/`SerialServer` sugar over the `serial://` scheme; reuses the AimX codec/dispatch and the session engines from core. Same compact AimX JSON, framed with **COBS** + a `0x00` sentinel (self-synchronizing on a lossy serial line). Dual halves: a std `tokio-runtime` (`tokio-serial`) riding the generic `Session*Connector`, and a `no_std + alloc` `embassy-runtime` generic over `embedded-io-async` UART halves that hand-rolls `ConnectorBuilder` and force-`Send`s the single-core futures via `SendFutureWrapper`. Cross-compiles to `thumbv7em-none-eabihf` (new Makefile `test-embedded` checks). Ships a real end-to-end demo: a host `serial_demo` example and an `embassy-serial-connector-demo` STM32H563ZI firmware (serves a record over USART3 ↔ the ST-LINK Virtual COM Port, queried from the host). The workspace `embedded-io-async` dep is bumped 0.6 → 0.7 to match the Embassy STM32 HAL. ([aimdb-serial-connector](aimdb-serial-connector/CHANGELOG.md)) +- **Transport-agnostic host client — pick the transport at runtime with `--connect ` (Issue #123, follow-up to #39 / #122).** `aimdb-client` gains an `endpoint` resolver (`unix://` / `uds://` / bare path / `serial://DEVICE?baud=N`) over a new `impl Dialer for Box` in `aimdb-core`; `AimxConnection::connect` takes an endpoint string, and transports are opt-in features (`transport-uds` default, `transport-serial` off-by-default). The `aimdb` CLI replaces the per-command `--socket` flags with a global `--connect` (+ `AIMDB_CONNECT`), and `aimdb-mcp` tools take an `endpoint` param with `--connect`/`AIMDB_CONNECT` (was `socket_path`/`--socket`/`AIMDB_SOCKET`). See **breaking** below. ([aimdb-client](aimdb-client/CHANGELOG.md), [tools/aimdb-cli](tools/aimdb-cli/CHANGELOG.md), [tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md), [aimdb-core](aimdb-core/CHANGELOG.md)) ### Changed (breaking) +- **CLI/MCP endpoint surface reworked to `--connect ` (Issue #123).** `aimdb`'s per-command `--socket ` is replaced by a global `--connect ` (`AIMDB_CONNECT` env; bare paths still work). `aimdb-mcp` renames the tools' `socket_path` param to `endpoint`, the startup `--socket` to `--connect`, and `AIMDB_SOCKET` to `AIMDB_CONNECT`; the pool is keyed by endpoint URL. `aimdb-client`'s `AimxConnection::connect` takes a `&str` endpoint (was a path) and `ClientError::ConnectionFailed.socket` is renamed `endpoint`. Serial endpoints (`serial://…`) require building the CLI/MCP with `--features transport-serial`. ([aimdb-client](aimdb-client/CHANGELOG.md), [tools/aimdb-cli](tools/aimdb-cli/CHANGELOG.md), [tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) + - **`AimDbBuilder::with_remote_access(config)` removed — remote-access servers are now registered like any other connector (Issue #39).** Replace `.with_remote_access(config)` with `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The AimX wire was reshaped to **v2** (NDJSON tagged frames mapping onto the engine's role-neutral message set) and is **not** backward-compatible with the legacy AimX v1 framing; the bundled `aimdb-client` / CLI / MCP speak v2. The UDS transport types moved out of `aimdb-core` into `aimdb-uds-connector`. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md)) - **M15 — `latest_snapshot` removed; point-in-time reads go through the new buffer-native `DynBuffer::peek()` ([Design 031](docs/design/031-M15-remove-latest-snapshot.md)).** `TypedRecord::latest()` and AimX `record.get` read the buffer directly instead of a per-record snapshot mutex (one lock + clone off the `produce()` hot path). Consequences: a `.with_remote_access()` record with **no buffer** now fails `build()` (was a silent runtime no-op); `record.get` / `latest()` on an `SpmcRing` record returns `not_found` / `None` (rings have no canonical latest — use `record.drain` / `record.subscribe`); `SingleLatest` and `Mailbox` are unaffected. `TypedRecord::produce` is removed — all writes go through `WriteHandle::push`. Adapters implement `peek()` per buffer type. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) - **M16 — `with_remote_access()` now requires the `json-serialize` feature (transitively enabled by `std`); `with_read_only_serialization()` removed ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** The stored serializer/deserializer closures are replaced by a type-erased `Arc>`. A `Serialize`-only record can no longer be exposed read-only over remote access. ([aimdb-core](aimdb-core/CHANGELOG.md)) diff --git a/Cargo.lock b/Cargo.lock index f7b0eb6..c542c3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ name = "aimdb-client" version = "0.6.0" dependencies = [ "aimdb-core", + "aimdb-serial-connector", "aimdb-tokio-adapter", "aimdb-uds-connector", "anyhow", @@ -1081,6 +1082,7 @@ dependencies = [ "aimdb-embassy-adapter", "aimdb-executor", "aimdb-knx-connector", + "aimdb-serial-connector", "cortex-m", "cortex-m-rt", "critical-section", @@ -1114,6 +1116,7 @@ dependencies = [ "aimdb-embassy-adapter", "aimdb-executor", "aimdb-mqtt-connector", + "aimdb-serial-connector", "cortex-m", "cortex-m-rt", "critical-section", @@ -3243,7 +3246,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-7aaa9af0001abcfb01c01e1a9b048697a82b7d57#f4d6b404521840db5ffd97712d29a557a22cbfa4" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-fb9d6e5e432ef51eaa686940d34b3487a50537a4#1a73224a4aaecff9b5ba9906595e12d21f34111d" dependencies = [ "cortex-m", "cortex-m-rt", @@ -3479,6 +3482,7 @@ dependencies = [ "aimdb-executor", "aimdb-knx-connector", "aimdb-tokio-adapter", + "aimdb-uds-connector", "knx-connector-demo-common", "serde", "serde_json", diff --git a/Makefile b/Makefile index 49fae72..a9b2cfc 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,8 @@ test: cargo test --package aimdb-core --features "std,connector-session" --test session_engine @printf "$(YELLOW) → Testing aimdb-client (engine-based AimX client + UDS round-trip)$(NC)\n" cargo test --package aimdb-client + @printf "$(YELLOW) → Testing aimdb-client (endpoint resolver, serial transport arm)$(NC)\n" + cargo test --package aimdb-client --no-default-features --features "transport-serial" @printf "$(YELLOW) → Testing tokio adapter$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing tokio adapter (with metrics)$(NC)\n" @@ -222,12 +224,18 @@ clippy: cargo clippy --package aimdb-sync --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on client library$(NC)\n" cargo clippy --package aimdb-client --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on client library (serial transport arm)$(NC)\n" + cargo clippy --package aimdb-client --no-default-features --features "transport-serial" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on codegen library$(NC)\n" cargo clippy --package aimdb-codegen --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on CLI tools$(NC)\n" cargo clippy --package aimdb-cli --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on CLI tools (serial transport)$(NC)\n" + cargo clippy --package aimdb-cli --features "transport-serial" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MCP server$(NC)\n" cargo clippy --package aimdb-mcp --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on MCP server (serial transport)$(NC)\n" + cargo clippy --package aimdb-mcp --features "transport-serial" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on persistence backend$(NC)\n" cargo clippy --package aimdb-persistence --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on persistence SQLite backend$(NC)\n" diff --git a/_external/embassy b/_external/embassy index caf0b35..6c28443 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit caf0b353e1f809a5eb22fc2d3bec337d1833e07e +Subproject commit 6c28443489ad5940ab8c1824c090b1f8a7233bf6 diff --git a/aimdb-client/CHANGELOG.md b/aimdb-client/CHANGELOG.md index 0bb0b55..a5efd2b 100644 --- a/aimdb-client/CHANGELOG.md +++ b/aimdb-client/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Transport-agnostic endpoint resolver — pick the transport at runtime via a `scheme://` URL (Issue #123, follow-up to #39 / #122).** New `endpoint` module: `parse_endpoint` (pure, feature-independent grammar) and `dial(url) -> Box` map an endpoint string to a transport `Dialer`, the way records already pick one for links. Schemes: `unix://PATH` / `uds://PATH`, a bare path (the `unix://` shorthand), and `serial://DEVICE?baud=N`. An unknown scheme — or one whose transport isn't compiled in — is rejected with a clear error. New `AimxConnection::connect_over(dialer)` / `connect_over_with_timeout` dial over an explicit `Dialer`, bypassing resolution. (Rides a new `impl Dialer for Box` in `aimdb-core`.) + ### Changed (breaking) +- **`AimxConnection::connect` now takes a `&str` endpoint, and transports are feature-gated (Issue #123).** `connect`/`connect_with_timeout` accept an endpoint string (was `impl AsRef`) — a `scheme://` URL or a bare path — resolved through the new `endpoint` module. The transports are now opt-in Cargo features: `transport-uds` (default; makes `aimdb-uds-connector` optional and gates the `discovery` module — a Unix-socket scan) and `transport-serial` (off by default; pulls `aimdb-serial-connector`, i.e. `tokio-serial` → libudev). `ClientError::ConnectionFailed`'s `socket` field is renamed `endpoint`, and a new `ClientError::UnsupportedEndpoint` covers malformed / not-built-in endpoints. The discovery `InstanceInfo.socket_path` field is likewise renamed `endpoint` — it now also carries a caller-supplied endpoint, not just a discovered socket path. - **`AimxClient` → `AimxConnection`, rebuilt on the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The synchronous `connection::AimxClient` is retired; the new `engine::AimxConnection` drives `aimdb-core`'s `run_client` engine over `aimdb-uds-connector`'s `UdsDialer` and speaks the reshaped **AimX-v2** protocol. Both the type and the module are re-exported from the crate root (`aimdb_client::AimxConnection`); the `connection` module is replaced by `engine`. `connect()` performs the `hello` handshake, and the full tool surface (list/get/set/subscribe/drain/graph/query) is available. - `subscribe(record_name)` now returns a `Stream` of updates directly — the engine routes events back by request id, so there is **no** server-allocated subscription id to track (the old `(subscription_id, queue_size)` handshake is gone). - New `connect_with_timeout(path, timeout)` bounds the whole dial + handshake (used by discovery probing). diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index 4fdd937..dc852d4 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -10,9 +10,16 @@ keywords = ["client", "protocol", "database", "remote", "rpc"] categories = ["database", "network-programming"] [features] +default = ["transport-uds"] metrics = ["aimdb-core/metrics"] profiling = ["aimdb-core/profiling"] +# Transports the endpoint resolver (`crate::endpoint`) can dial. Each gates its +# connector dep + the matching `dial`/`parse` arm; an endpoint whose scheme isn't +# compiled in is rejected at resolve time. +transport-uds = ["dep:aimdb-uds-connector"] +transport-serial = ["dep:aimdb-serial-connector"] + [dependencies] # Core dependencies - protocol types from aimdb-core. `connector-session` # exposes the shared session engine (`run_client`/`ClientHandle`) plus the AimX @@ -22,9 +29,12 @@ aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ "connector-session", ] } -# The UDS transport (`UdsDialer`) relocated out of core in Phase 6; the -# engine-based client dials over it. -aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector" } +# Transports the resolver dials over (relocated out of core in Phase 6), each +# behind its `transport-*` feature so a binary links only what it needs. +aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector", optional = true } +aimdb-serial-connector = { version = "0.1.0", path = "../aimdb-serial-connector", default-features = false, features = [ + "tokio-runtime", +], optional = true } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/aimdb-client/src/discovery.rs b/aimdb-client/src/discovery.rs index 3489b04..347b233 100644 --- a/aimdb-client/src/discovery.rs +++ b/aimdb-client/src/discovery.rs @@ -5,7 +5,7 @@ use crate::engine::AimxConnection; use crate::error::{ClientError, ClientResult}; use crate::protocol::WelcomeMessage; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; /// Known directories where AimDB sockets might be located @@ -14,7 +14,7 @@ const SOCKET_SEARCH_DIRS: &[&str] = &["/tmp", "/var/run/aimdb"]; /// Information about a discovered AimDB instance #[derive(Debug, Clone)] pub struct InstanceInfo { - pub socket_path: PathBuf, + pub endpoint: PathBuf, pub server_version: String, pub protocol_version: String, pub permissions: Vec, @@ -24,9 +24,9 @@ pub struct InstanceInfo { } impl From<(PathBuf, WelcomeMessage)> for InstanceInfo { - fn from((socket_path, welcome): (PathBuf, WelcomeMessage)) -> Self { + fn from((endpoint, welcome): (PathBuf, WelcomeMessage)) -> Self { Self { - socket_path, + endpoint, server_version: welcome.server, protocol_version: welcome.version, permissions: welcome.permissions, @@ -74,16 +74,18 @@ async fn scan_directory(mut entries: tokio::fs::ReadDir) -> Vec { } /// Try to connect to a socket and get instance information -async fn probe_instance(socket_path: &PathBuf) -> ClientResult { +async fn probe_instance(socket_path: &Path) -> ClientResult { // `connect_with_timeout` bounds the whole handshake (dial + hello), so a stale // socket whose peer accepts but never replies fails fast instead of hanging — // no need to wrap a second timeout around `connect`. let connect_timeout = Duration::from_millis(500); - let client = AimxConnection::connect_with_timeout(socket_path, connect_timeout).await?; + // A discovered socket path is dialed as the bare-path (`unix://`) shorthand. + let endpoint = socket_path.to_string_lossy(); + let client = AimxConnection::connect_with_timeout(&endpoint, connect_timeout).await?; let welcome = client.server_info().clone(); - Ok(InstanceInfo::from((socket_path.clone(), welcome))) + Ok(InstanceInfo::from((socket_path.to_path_buf(), welcome))) } /// Find a specific instance by socket path or name diff --git a/aimdb-client/src/endpoint.rs b/aimdb-client/src/endpoint.rs new file mode 100644 index 0000000..4d5dd34 --- /dev/null +++ b/aimdb-client/src/endpoint.rs @@ -0,0 +1,254 @@ +//! Endpoint resolution — map a `scheme://` URL (or a bare path) to a transport +//! [`Dialer`], so an operator can pick the transport at runtime the same way +//! records pick one for links. +//! +//! Two layers, deliberately split so the grammar is testable without any +//! transport compiled in: +//! - [`parse_endpoint`] — **pure, feature-independent**. Recognizes the scheme +//! grammar (`unix://` / `uds://` / `serial://`, plus a bare path as `unix://` +//! shorthand) into a [`ParsedEndpoint`]. An unknown scheme is rejected here. +//! - [`dial`] — builds the concrete [`Dialer`] for a parsed endpoint, under the +//! matching `transport-*` feature. A scheme whose transport isn't compiled into +//! this binary is rejected here (distinct from "unknown scheme"). +//! +//! TCP is intentionally absent for now (tracked separately); adding it is a new +//! [`Scheme`] arm plus a `dial` branch. + +use aimdb_core::session::Dialer; + +use crate::error::{ClientError, ClientResult}; + +/// Default serial baud when a `serial://` endpoint omits `?baud=`. +pub const DEFAULT_SERIAL_BAUD: u32 = 115_200; + +/// Schemes the resolver's *grammar* understands, independent of which transports +/// are compiled in. Used to phrase the "unknown scheme" error. +const KNOWN_SCHEMES: &[&str] = &["unix", "uds", "serial"]; + +/// The transport family an endpoint names. Always compiled (it is grammar, not a +/// capability) — whether a given variant can actually be dialed depends on the +/// `transport-*` features, enforced in [`dial`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + /// A Unix-domain socket (`unix://` / `uds://`, or a bare path). + Unix, + /// A serial/UART device (`serial://`). + Serial, +} + +/// A parsed endpoint: the transport family plus its target and any transport +/// options (currently just the serial baud). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedEndpoint { + /// Which transport family this endpoint names. + pub scheme: Scheme, + /// The transport target — a socket path (`Unix`) or device path (`Serial`). + pub target: String, + /// Serial baud from `?baud=N`, if given (`Serial` only; [`dial`] defaults it + /// to [`DEFAULT_SERIAL_BAUD`]). + pub baud: Option, +} + +/// Parse an endpoint string into a [`ParsedEndpoint`] (pure; no transport needed). +/// +/// - `unix://PATH` / `uds://PATH` → [`Scheme::Unix`]. +/// - a bare path (no `scheme://`) → [`Scheme::Unix`] (the shorthand). +/// - `serial://PATH` (optionally `?baud=N`) → [`Scheme::Serial`]. +/// - anything else (e.g. `tcp://…`) → [`ClientError::UnsupportedEndpoint`]. +pub fn parse_endpoint(endpoint: &str) -> ClientResult { + let endpoint = endpoint.trim(); + if endpoint.is_empty() { + return Err(ClientError::unsupported_endpoint("", "empty endpoint")); + } + + // No `scheme://` → a bare path is the `unix://` shorthand. + let Some((scheme, rest)) = endpoint.split_once("://") else { + return Ok(ParsedEndpoint { + scheme: Scheme::Unix, + target: endpoint.to_string(), + baud: None, + }); + }; + + match scheme.to_ascii_lowercase().as_str() { + "unix" | "uds" => { + require_nonempty(endpoint, rest)?; + Ok(ParsedEndpoint { + scheme: Scheme::Unix, + target: rest.to_string(), + baud: None, + }) + } + "serial" => { + // Split off an optional `?baud=N[&…]` query; unknown query keys are + // ignored for forward-compat, but a malformed `baud` is an error. + let (path, query) = rest.split_once('?').unwrap_or((rest, "")); + require_nonempty(endpoint, path)?; + let baud = parse_baud(endpoint, query)?; + Ok(ParsedEndpoint { + scheme: Scheme::Serial, + target: path.to_string(), + baud, + }) + } + other => Err(ClientError::unsupported_endpoint( + endpoint, + format!( + "unknown scheme {other:?}; built-in schemes: {}", + KNOWN_SCHEMES.join(", ") + ), + )), + } +} + +/// Resolve an endpoint string to a boxed [`Dialer`] for the linked-in transport. +/// +/// Errors with [`ClientError::UnsupportedEndpoint`] if the string is malformed, +/// names an unknown scheme, or names a scheme whose transport isn't compiled into +/// this binary. +pub fn dial(endpoint: &str) -> ClientResult> { + let parsed = parse_endpoint(endpoint)?; + match parsed.scheme { + Scheme::Unix => { + #[cfg(feature = "transport-uds")] + { + Ok(Box::new(aimdb_uds_connector::UdsDialer::new(parsed.target))) + } + #[cfg(not(feature = "transport-uds"))] + { + Err(not_built_in(endpoint, "unix", "transport-uds")) + } + } + Scheme::Serial => { + #[cfg(feature = "transport-serial")] + { + Ok(Box::new(aimdb_serial_connector::SerialDialer::new( + parsed.target, + parsed.baud.unwrap_or(DEFAULT_SERIAL_BAUD), + ))) + } + #[cfg(not(feature = "transport-serial"))] + { + Err(not_built_in(endpoint, "serial", "transport-serial")) + } + } + } +} + +/// Reject an empty target (e.g. `unix://`), pointing at the original endpoint. +fn require_nonempty(endpoint: &str, target: &str) -> ClientResult<()> { + if target.is_empty() { + Err(ClientError::unsupported_endpoint( + endpoint, + "missing path after scheme", + )) + } else { + Ok(()) + } +} + +/// Pull `baud` out of a `serial://` query string (`baud=N[&k=v…]`). +fn parse_baud(endpoint: &str, query: &str) -> ClientResult> { + for pair in query.split('&').filter(|p| !p.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key.eq_ignore_ascii_case("baud") { + let baud = value.parse::().map_err(|_| { + ClientError::unsupported_endpoint(endpoint, format!("invalid baud {value:?}")) + })?; + return Ok(Some(baud)); + } + } + Ok(None) +} + +/// A recognized scheme whose transport feature isn't compiled in. +#[cfg(any(not(feature = "transport-uds"), not(feature = "transport-serial")))] +fn not_built_in(endpoint: &str, scheme: &str, feature: &str) -> ClientError { + ClientError::unsupported_endpoint( + endpoint, + format!( + "scheme {scheme:?} is not built into this binary (rebuild with --features {feature})" + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_scheme_and_bare_path_both_resolve_to_unix() { + for ep in ["unix:///tmp/aimdb.sock", "uds:///tmp/aimdb.sock"] { + let p = parse_endpoint(ep).expect("parse"); + assert_eq!(p.scheme, Scheme::Unix); + assert_eq!(p.target, "/tmp/aimdb.sock"); + assert_eq!(p.baud, None); + } + // Bare paths (absolute + relative) are the `unix://` shorthand. + for bare in ["/tmp/aimdb.sock", "./rel.sock"] { + let p = parse_endpoint(bare).expect("parse"); + assert_eq!(p.scheme, Scheme::Unix); + assert_eq!(p.target, bare); + } + } + + #[test] + fn serial_scheme_parses_path_and_optional_baud() { + let p = parse_endpoint("serial:///dev/ttyACM0?baud=9600").expect("parse"); + assert_eq!(p.scheme, Scheme::Serial); + assert_eq!(p.target, "/dev/ttyACM0"); + assert_eq!(p.baud, Some(9600)); + + // No query → baud unset (dial defaults it to DEFAULT_SERIAL_BAUD). + let p = parse_endpoint("serial:///dev/ttyUSB0").expect("parse"); + assert_eq!(p.baud, None); + + // Unknown query keys are ignored; baud is still picked up. + let p = parse_endpoint("serial:///dev/ttyUSB0?foo=bar&baud=230400").expect("parse"); + assert_eq!(p.baud, Some(230400)); + } + + #[test] + fn malformed_endpoints_are_rejected() { + // Unknown scheme. + assert!(matches!( + parse_endpoint("tcp://host:1234"), + Err(ClientError::UnsupportedEndpoint { .. }) + )); + // Empty + empty target. + assert!(parse_endpoint("").is_err()); + assert!(parse_endpoint("unix://").is_err()); + // Non-numeric baud. + assert!(parse_endpoint("serial:///dev/x?baud=fast").is_err()); + } + + #[test] + fn dial_rejects_unknown_scheme() { + assert!(matches!( + dial("tcp://host:1234"), + Err(ClientError::UnsupportedEndpoint { .. }) + )); + } + + #[cfg(feature = "transport-uds")] + #[test] + fn dial_builds_a_unix_dialer() { + assert!(dial("unix:///tmp/aimdb.sock").is_ok()); + assert!(dial("/tmp/aimdb.sock").is_ok()); + } + + #[cfg(not(feature = "transport-serial"))] + #[test] + fn dial_rejects_serial_when_not_built_in() { + assert!(matches!( + dial("serial:///dev/ttyACM0"), + Err(ClientError::UnsupportedEndpoint { .. }) + )); + } + + #[cfg(feature = "transport-serial")] + #[test] + fn dial_builds_a_serial_dialer() { + assert!(dial("serial:///dev/ttyACM0?baud=115200").is_ok()); + } +} diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 870a84a..f622b02 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -11,7 +11,6 @@ //! engine on a `tokio::spawn`ed task held by [`AimxConnection`]. Dropping the //! connection drops the handle, which stops the engine gracefully. -use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -22,9 +21,10 @@ use tokio::task::JoinHandle; use tokio::time::timeout; use aimdb_core::session::aimx::AimxCodec; -use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; +use aimdb_core::session::{ + run_client, BoxStream, ClientConfig, ClientHandle, Dialer, Payload, RpcError, +}; use aimdb_tokio_adapter::TokioAdapter; -use aimdb_uds_connector::UdsDialer; use crate::error::{ClientError, ClientResult}; use crate::protocol::{RecordMetadata, WelcomeMessage}; @@ -58,30 +58,53 @@ pub struct AimxConnection { } impl AimxConnection { - /// Dial `socket_path`, start the engine, and complete the `hello` handshake, - /// bounded by [`DEFAULT_CONNECT_TIMEOUT`]. + /// Resolve `endpoint` to a transport, start the engine, and complete the + /// `hello` handshake, bounded by [`DEFAULT_CONNECT_TIMEOUT`]. /// - /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than - /// a privileged frame — the reshaped wire's deliberate simplification. A dial - /// failure surfaces here as the `hello` call failing (the engine runs with - /// reconnect off so connect-time errors are prompt); a peer that accepts but - /// never replies surfaces as a timeout (see [`connect_with_timeout`](Self::connect_with_timeout)). - pub async fn connect(socket_path: impl AsRef) -> ClientResult { - Self::connect_with_timeout(socket_path, DEFAULT_CONNECT_TIMEOUT).await + /// `endpoint` is a `scheme://` URL — `unix://PATH` / `uds://PATH`, + /// `serial://DEVICE?baud=N` — or a bare path (the `unix://` shorthand). The + /// scheme picks the transport via [`crate::endpoint::dial`]; an unknown scheme + /// (or one whose transport isn't compiled in) fails before the engine starts. + pub async fn connect(endpoint: &str) -> ClientResult { + Self::connect_with_timeout(endpoint, DEFAULT_CONNECT_TIMEOUT).await } /// Like [`connect`](Self::connect), but with an explicit handshake deadline. - /// - /// The deadline covers the whole handshake — dial *and* the `hello`/Welcome - /// exchange — so a silent or unresponsive peer cannot block the caller - /// indefinitely. On timeout (or any failure) the engine task is aborted so it - /// does not linger blocked on a stalled connection. pub async fn connect_with_timeout( - socket_path: impl AsRef, + endpoint: &str, + connect_timeout: Duration, + ) -> ClientResult { + let dialer = crate::endpoint::dial(endpoint)?; + Self::spin_up(dialer, endpoint, connect_timeout).await + } + + /// Connect over an explicit [`Dialer`], bypassing endpoint resolution — for + /// transports the resolver doesn't cover, or a pre-built dialer. + pub async fn connect_over(dialer: impl Dialer + 'static) -> ClientResult { + Self::connect_over_with_timeout(dialer, DEFAULT_CONNECT_TIMEOUT).await + } + + /// Like [`connect_over`](Self::connect_over), with an explicit handshake deadline. + pub async fn connect_over_with_timeout( + dialer: impl Dialer + 'static, + connect_timeout: Duration, + ) -> ClientResult { + Self::spin_up(dialer, "remote peer", connect_timeout).await + } + + /// Spin up the engine over `dialer` and complete the `hello` handshake. + /// + /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than + /// a privileged frame — the reshaped wire's deliberate simplification. The + /// `connect_timeout` covers the whole handshake (dial *and* the Welcome + /// exchange), so a silent or unresponsive peer can't block the caller; on any + /// failure the engine task is aborted so it doesn't linger blocked. `label` + /// names the endpoint in error messages. + async fn spin_up( + dialer: impl Dialer + 'static, + label: &str, connect_timeout: Duration, ) -> ClientResult { - let path = socket_path.as_ref(); - let dialer = UdsDialer::new(path); let config = ClientConfig { reconnect: false, sends_hello: false, @@ -90,21 +113,14 @@ impl AimxConnection { let (handle, engine_fut) = run_client(dialer, AimxCodec, config, Arc::new(TokioAdapter)); let engine = tokio::spawn(engine_fut); - // Handshake-as-RPC: the server replies with its Welcome. Bounded so an - // accepted-but-silent peer times out instead of hanging forever. let server_info = async { let hello = json!({ "client": "aimdb-client" }); let reply = timeout(connect_timeout, handle.call("hello", to_payload(&hello)?)) .await + .map_err(|_| ClientError::connection_failed(label, "handshake timed out"))? .map_err(|_| { ClientError::connection_failed( - path.display().to_string(), - "handshake timed out", - ) - })? - .map_err(|_| { - ClientError::connection_failed( - path.display().to_string(), + label, "handshake failed (engine could not reach server)", ) })?; diff --git a/aimdb-client/src/error.rs b/aimdb-client/src/error.rs index 9047bf5..042968a 100644 --- a/aimdb-client/src/error.rs +++ b/aimdb-client/src/error.rs @@ -13,8 +13,13 @@ pub enum ClientError { NoInstancesFound, /// Connection error - #[error("Connection failed to {socket}: {reason}")] - ConnectionFailed { socket: String, reason: String }, + #[error("Connection failed to {endpoint}: {reason}")] + ConnectionFailed { endpoint: String, reason: String }, + + /// The endpoint string was malformed, or named a `scheme://` whose transport + /// is not compiled into this build. + #[error("Unsupported endpoint {endpoint:?}: {reason}")] + UnsupportedEndpoint { endpoint: String, reason: String }, /// Server returned an error #[error("Server error (code {code}): {message}")] @@ -39,9 +44,17 @@ pub enum ClientError { impl ClientError { /// Create a connection failed error - pub fn connection_failed(socket: impl Into, reason: impl Into) -> Self { + pub fn connection_failed(endpoint: impl Into, reason: impl Into) -> Self { Self::ConnectionFailed { - socket: socket.into(), + endpoint: endpoint.into(), + reason: reason.into(), + } + } + + /// Create an unsupported-endpoint error (bad URL, or a scheme not built in). + pub fn unsupported_endpoint(endpoint: impl Into, reason: impl Into) -> Self { + Self::UnsupportedEndpoint { + endpoint: endpoint.into(), reason: reason.into(), } } diff --git a/aimdb-client/src/lib.rs b/aimdb-client/src/lib.rs index 7d17311..86bfdb3 100644 --- a/aimdb-client/src/lib.rs +++ b/aimdb-client/src/lib.rs @@ -19,8 +19,9 @@ //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // Connect to an AimDB instance (performs the `hello` handshake). -//! let conn = AimxConnection::connect("/tmp/aimdb.sock").await?; +//! // Connect to an AimDB instance by endpoint (a bare path is the `unix://` +//! // shorthand; `serial://DEVICE?baud=N` reaches a board). Performs `hello`. +//! let conn = AimxConnection::connect("unix:///tmp/aimdb.sock").await?; //! //! // List all records //! let records = conn.list_records().await?; @@ -34,14 +35,20 @@ //! } //! ``` +// Instance discovery is a Unix-socket filesystem scan, so it rides the UDS +// transport feature. +#[cfg(feature = "transport-uds")] pub mod discovery; +pub mod endpoint; pub mod engine; pub mod error; pub mod protocol; // Re-export main types for convenience. `AimxConnection` is the engine-based // client (the synchronous `AimxClient` was retired with the AimX server port). +#[cfg(feature = "transport-uds")] pub use discovery::{discover_instances, find_instance, InstanceInfo}; +pub use endpoint::{dial, parse_endpoint, ParsedEndpoint, Scheme}; pub use engine::{AimxConnection, DrainResponse}; pub use error::{ClientError, ClientResult}; pub use protocol::{ diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index d186d7b..514bacf 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -5,6 +5,9 @@ //! standing up an actual `AimDb` and proving the wire end-to-end through the //! shared session engine. +// Exercises the UDS transport end-to-end, so it rides that transport feature. +#![cfg(feature = "transport-uds")] + use std::sync::Arc; use std::time::Duration; @@ -66,7 +69,9 @@ async fn aimx_roundtrip_over_uds_production_server() { .expect("seed setting"); // Connect: performs the `hello` handshake and captures the Welcome. - let conn = AimxConnection::connect(&sock).await.expect("connect"); + let conn = AimxConnection::connect(sock.to_str().unwrap()) + .await + .expect("connect"); assert_eq!(conn.server_info().server, "aimdb"); assert!(conn .server_info() @@ -156,7 +161,9 @@ async fn record_get_on_ring_falls_back_to_drain() { let db = Arc::new(db); tokio::spawn(runner.run()); - let conn = AimxConnection::connect(&sock).await.expect("connect"); + let conn = AimxConnection::connect(sock.to_str().unwrap()) + .await + .expect("connect"); let producer = db.producer::("stream").expect("producer"); // First get opens the cursor; a fresh broadcast reader starts at the tail, so diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 12e5c41..0ada9ba 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -9,6 +9,9 @@ //! - **server → client**: updating the server's `tele` record streams it back //! through a subscription → the client's inbound producer (arbiter path). +// Exercises the UDS transport end-to-end, so it rides that transport feature. +#![cfg(feature = "transport-uds")] + use std::sync::Arc; use std::time::Duration; diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 20e7831..f763687 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`ConnectorConfig::from_query(&[(String, String)])`.** Builds a per-route `ConnectorConfig` from a link URL's query pairs (`timeout_ms` lifted to the typed field, everything else passed through in `protocol_options`); the seam `pump_sink` uses to thread per-route config to `Connector::publish`. - **`json-serialize` feature + `codec` module (M16, Design 032).** New `crate::codec` module with `RemoteSerialize` (capability trait, blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec` storage trait, and the zero-sized `SerdeJsonCodec`. All three are re-exported from the crate root. The feature is `no_std + alloc` compatible (`serde_json` runs on `alloc`), so `RecordValue::as_json()` now works on embedded targets, not just `std`. `std` enables `json-serialize` transitively, so existing std builds are unaffected. - **`DynBuffer::peek(&self) -> Option` (M15, Design 031).** Non-destructive, buffer-native point-in-time read; the default impl returns `None` (correct for buffers with no canonical latest, e.g. broadcast/SPMC rings). AimX `record.get` and `TypedRecord::latest()` now route through it. Adapters implement it per buffer type — see the tokio/embassy adapter changelogs. +- **`impl Dialer for Box` (Issue #123).** A boxed dialer is itself a `Dialer`, so a runtime-selected `Box` (e.g. from a `scheme://` URL resolver) can be handed straight to `run_client` without a generic transport at the call site. ### Internal refactors diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 825376c..783d6aa 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -304,6 +304,16 @@ pub trait Dialer: Send { fn connect(&self) -> BoxFut<'_, TransportResult>>; } +/// A boxed dialer is itself a [`Dialer`], so a runtime-selected +/// `Box` (e.g. from a `scheme://` URL resolver) can be handed +/// straight to [`run_client`]`` without a generic transport at the +/// call site. `dyn Dialer: Send` (supertrait) makes the box `Send + 'static`. +impl Dialer for Box { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + (**self).connect() + } +} + // =========================================================================== // Layer 3 — dispatch. RPC and streaming unify in one per-connection role with // three reply cardinalities: `call` (one) / `subscribe` (many) / `write` (none). @@ -532,4 +542,14 @@ mod tests { let _codec: Box = Box::new(MockCodec); let _source: Box = Box::new(MockSource); } + + /// `Box` satisfies the `Dialer` bound, so a runtime-selected + /// dialer (the URL resolver's return type) can be passed where `D: Dialer` + /// is expected. Compile-time proof via a generic that requires the bound. + #[test] + fn boxed_dialer_is_a_dialer() { + fn takes_dialer(_d: D) {} + let boxed: Box = Box::new(MockDialer); + takes_dialer(boxed); + } } diff --git a/aimdb-serial-connector/CHANGELOG.md b/aimdb-serial-connector/CHANGELOG.md index 8cd677d..f2b6244 100644 --- a/aimdb-serial-connector/CHANGELOG.md +++ b/aimdb-serial-connector/CHANGELOG.md @@ -40,5 +40,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`embedded-io-async` is pinned to 0.7** (the workspace dep was bumped 0.6 → 0.7) so a HAL `BufferedUart` (e.g. `embassy-stm32`, which uses 0.7) satisfies the connector's `Read`/`Write` bounds without a trait-version skew. Wire the Embassy half from a `BufferedUart::split()` — the plain async `UartRx` does **not** implement `embedded-io-async::Read`, only the buffered/ring-buffered variants do. - The `unsafe impl Send`/`Sync` on the Embassy transport + builder types rest on the single-core, cooperative Embassy-executor invariant documented by `SendFutureWrapper` (no preemption / thread migration). This is the first *raw-peripheral* session connector — MQTT/KNX sidestep it by pulling a `Send + Sync` `embassy_net::Stack` from the runtime adapter rather than owning a peripheral. -- The `serial://` scheme constant lands here; the `connect_url` / `--connect ` resolver that maps a `serial:///dev/ttyUSB0?baud=115200` URL to `SerialClient`/`SerialServer` is tracked separately (Issue #123). +- The `serial://` scheme constant lands here; the host-side `--connect serial:///dev/ttyUSB0?baud=115200` resolver that maps that URL to a `SerialDialer` landed in `aimdb-client`'s `endpoint` module (Issue #123), wired into `aimdb-cli`/`aimdb-mcp` behind their `transport-serial` feature. - The internal `_test-tokio` feature gates the host tokio integration test's adapter dependency; it is kept off the public `tokio-runtime` feature (a connector shouldn't pull a concrete adapter) and out of `[dev-dependencies]` (an unconditional tokio adapter would force `aimdb-core/std` into the `no_std` embassy test build). diff --git a/examples/embassy-knx-connector-demo/Cargo.toml b/examples/embassy-knx-connector-demo/Cargo.toml index 5ce8451..46cf40e 100644 --- a/examples/embassy-knx-connector-demo/Cargo.toml +++ b/examples/embassy-knx-connector-demo/Cargo.toml @@ -25,11 +25,20 @@ aimdb-knx-connector = { path = "../../aimdb-knx-connector", default-features = f "embassy-runtime", "defmt", ] } +# Serial remote-access server — serves this db's records over a UART (ST-LINK VCP) +# so a host can read them with `aimdb --features transport-serial --connect serial://…`. +# Its `embassy-runtime` feature also turns on `aimdb-core/remote-access`. +aimdb-serial-connector = { path = "../../aimdb-serial-connector", default-features = false, features = [ + "embassy-runtime", + "defmt", +] } -# Shared demo types and monitors +# Shared demo types and monitors. `serde` makes the shared types serde-codec'able +# in no_std so they can be exposed over the serial remote-access server. knx-connector-demo-common = { path = "../knx-connector-demo-common", default-features = false, features = [ "alloc", "derive", + "serde", ] } # Embassy ecosystem - STM32H563ZI with Ethernet diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 61b22f3..6afe046 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -40,12 +40,14 @@ extern crate alloc; +use aimdb_core::remote::SecurityPolicy; use aimdb_core::{AimDbBuilder, RecordKey, RuntimeContext}; use aimdb_embassy_adapter::{ EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt, EmbassyRecordRegistrarExtCustom, }; use aimdb_knx_connector::dpt::{Dpt1, Dpt9, DptDecode, DptEncode}; use aimdb_knx_connector::embassy_client::KnxConnectorBuilder; +use aimdb_serial_connector::embassy_transport::SerialServer; use defmt::*; use embassy_executor::Spawner; use embassy_net::StackResources; @@ -54,7 +56,8 @@ use embassy_stm32::exti::{self, ExtiInput}; use embassy_stm32::gpio::{Level, Output, Pull, Speed}; use embassy_stm32::peripherals::ETH; use embassy_stm32::rng::Rng; -use embassy_stm32::{Config, bind_interrupts, eth, interrupt, peripherals, rng}; +use embassy_stm32::usart::{BufferedUart, Config as UartConfig}; +use embassy_stm32::{Config, bind_interrupts, eth, interrupt, peripherals, rng, usart}; use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; @@ -74,6 +77,7 @@ bind_interrupts!(struct Irqs { ETH => eth::InterruptHandler; RNG => rng::InterruptHandler; EXTI13 => exti::InterruptHandler; + USART3 => usart::BufferedInterruptHandler; }); type Device = @@ -137,7 +141,7 @@ async fn main(spawner: Spawner) { // Initialize heap for the allocator { use core::mem::MaybeUninit; - const HEAP_SIZE: usize = 32768; // 32KB heap + const HEAP_SIZE: usize = 49152; // 48KB heap (KNX + serial AimX server JSON) static mut HEAP: [MaybeUninit; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; unsafe { let heap_ptr = core::ptr::addr_of_mut!(HEAP); @@ -245,9 +249,36 @@ async fn main(spawner: Spawner) { use alloc::format; let gateway_url = format!("knx://{}:{}", KNX_GATEWAY_IP, KNX_GATEWAY_PORT); + // ── AimX-over-serial: serve this db over USART3 (ST-LINK VCP, PD8=TX/PD9=RX) ── + // A *second* connector alongside KNX. With no extra cabling on a Nucleo-H563ZI + // it appears on the host as /dev/ttyACM0; read the live records with: + // aimdb --features transport-serial \ + // --connect serial:///dev/ttyACM0?baud=115200 record list + // defmt logs ride RTT (SWD), so they don't collide with this data UART. + static TX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static RX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + let mut uart_config = UartConfig::default(); + uart_config.baudrate = 115_200; + let uart = BufferedUart::new( + p.USART3, + p.PD9, // RX + p.PD8, // TX + TX_BUF.init([0; 256]), + RX_BUF.init([0; 256]), + Irqs, + uart_config, + ) + .unwrap(); + let (serial_tx, serial_rx) = uart.split(); + + // Read-only: KNX owns the writer for every record (single-writer-per-key), so + // remote `record.set` is refused — peers can list/get/subscribe, not write. let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector(KnxConnectorBuilder::new(&gateway_url)); + .with_connector(KnxConnectorBuilder::new(&gateway_url)) + .with_connector( + SerialServer::new(serial_rx, serial_tx).security_policy(SecurityPolicy::read_only()), + ); // ======================================================================== // TEMPERATURE SENSORS (inbound: KNX → AimDB) @@ -256,6 +287,7 @@ async fn main(spawner: Spawner) { builder.configure::(TemperatureKey::LivingRoom, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -267,6 +299,7 @@ async fn main(spawner: Spawner) { builder.configure::(TemperatureKey::Bedroom, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -278,6 +311,7 @@ async fn main(spawner: Spawner) { builder.configure::(TemperatureKey::Kitchen, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -294,6 +328,7 @@ async fn main(spawner: Spawner) { builder.configure::(LightKey::Main, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -305,6 +340,7 @@ async fn main(spawner: Spawner) { builder.configure::(LightKey::Hallway, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -321,6 +357,7 @@ async fn main(spawner: Spawner) { builder.configure::(LightControlKey::Control, |reg| { reg.buffer_sized::<4, 2>(EmbassyBufferType::SingleLatest) + .with_remote_access() .source_with_context(button, button_handler) .link_to(LightControlKey::Control.link_address().unwrap()) .with_serializer_raw(|state: &LightControl| { @@ -341,6 +378,10 @@ async fn main(spawner: Spawner) { info!(" OUTBOUND (AimDB → KNX):"); info!(" - lights.control (1/0/6)"); info!(" Gateway: {}:{}", KNX_GATEWAY_IP, KNX_GATEWAY_PORT); + info!(" SERIAL (read-only AimX over USART3 / ST-LINK VCP):"); + info!( + " aimdb --features transport-serial --connect serial:///dev/ttyACM0?baud=115200 record list" + ); info!(""); info!("Press USER button to toggle light (1/0/6)"); diff --git a/examples/embassy-mqtt-connector-demo/Cargo.toml b/examples/embassy-mqtt-connector-demo/Cargo.toml index f4dc7bf..1034dfd 100644 --- a/examples/embassy-mqtt-connector-demo/Cargo.toml +++ b/examples/embassy-mqtt-connector-demo/Cargo.toml @@ -25,11 +25,20 @@ aimdb-executor = { path = "../../aimdb-executor", default-features = false, feat aimdb-mqtt-connector = { path = "../../aimdb-mqtt-connector", default-features = false, features = [ "embassy-runtime", ] } +# Serial remote-access server — serves this db's records over a UART (ST-LINK VCP) +# so a host can read them with `aimdb --features transport-serial --connect serial://…`. +# Its `embassy-runtime` feature also turns on `aimdb-core/remote-access`. +aimdb-serial-connector = { path = "../../aimdb-serial-connector", default-features = false, features = [ + "embassy-runtime", + "defmt", +] } -# Shared demo logic +# Shared demo logic. `serde` makes the shared types serde-codec'able in no_std so +# they can be exposed over the serial remote-access server. mqtt-connector-demo-common = { path = "../mqtt-connector-demo-common", features = [ "alloc", "derive", + "serde", ] } # Embassy ecosystem - STM32H563ZI with Ethernet diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 8f112b8..f841f61 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -52,10 +52,12 @@ extern crate alloc; +use aimdb_core::remote::SecurityPolicy; use aimdb_core::{AimDbBuilder, Producer, RecordKey, RuntimeContext}; use aimdb_embassy_adapter::{ EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt, EmbassyRecordRegistrarExtCustom, }; +use aimdb_serial_connector::embassy_transport::SerialServer; use defmt::*; use embassy_executor::Spawner; use embassy_net::StackResources; @@ -63,7 +65,8 @@ use embassy_stm32::eth::{Ethernet, GenericPhy, PacketQueue}; use embassy_stm32::gpio::{Level, Output, Speed}; use embassy_stm32::peripherals::ETH; use embassy_stm32::rng::Rng; -use embassy_stm32::{Config, bind_interrupts, eth, peripherals, rng}; +use embassy_stm32::usart::{BufferedUart, Config as UartConfig}; +use embassy_stm32::{Config, bind_interrupts, eth, peripherals, rng, usart}; use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; @@ -83,6 +86,7 @@ static ALLOCATOR: embedded_alloc::LlffHeap = embedded_alloc::LlffHeap::empty(); bind_interrupts!(struct Irqs { ETH => eth::InterruptHandler; RNG => rng::InterruptHandler; + USART3 => usart::BufferedInterruptHandler; }); type Device = @@ -185,7 +189,7 @@ async fn main(spawner: Spawner) { // Initialize heap for the allocator { use core::mem::MaybeUninit; - const HEAP_SIZE: usize = 32768; // 32KB heap + const HEAP_SIZE: usize = 49152; // 48KB heap (MQTT + serial AimX server JSON) static mut HEAP: [MaybeUninit; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; unsafe { let heap_ptr = core::ptr::addr_of_mut!(HEAP); @@ -308,9 +312,38 @@ async fn main(spawner: Spawner) { use alloc::format; let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + // ── AimX-over-serial: serve this db over USART3 (ST-LINK VCP, PD8=TX/PD9=RX) ── + // A *second* connector alongside MQTT. With no extra cabling on a Nucleo-H563ZI + // it appears on the host as /dev/ttyACM0; read the live records with: + // aimdb --features transport-serial \ + // --connect serial:///dev/ttyACM0?baud=115200 record list + // (sensor records are SpmcRing → use `record drain`/`watch`; `get` has no + // canonical latest). defmt logs ride RTT (SWD), separate from this data UART. + static TX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static RX_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + let mut uart_config = UartConfig::default(); + uart_config.baudrate = 115_200; + let uart = BufferedUart::new( + p.USART3, + p.PD9, // RX + p.PD8, // TX + TX_BUF.init([0; 256]), + RX_BUF.init([0; 256]), + Irqs, + uart_config, + ) + .unwrap(); + let (serial_tx, serial_rx) = uart.split(); + + // Read-only: each record has a single writer (a sensor source, or MQTT for the + // command records), so remote `record.set` is refused — peers can + // list/drain/subscribe, not write. let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector(MqttConnectorBuilder::new(&broker_url).with_client_id("embassy-demo-001")); + .with_connector(MqttConnectorBuilder::new(&broker_url).with_client_id("embassy-demo-001")) + .with_connector( + SerialServer::new(serial_rx, serial_tx).security_policy(SecurityPolicy::read_only()), + ); // ======================================================================== // TEMPERATURE SENSORS (outbound: AimDB → MQTT) @@ -319,6 +352,7 @@ async fn main(spawner: Spawner) { builder.configure::(SensorKey::TempIndoor, |reg| { reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) + .with_remote_access() .source(indoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempIndoor.link_address().unwrap()) @@ -328,6 +362,7 @@ async fn main(spawner: Spawner) { builder.configure::(SensorKey::TempOutdoor, |reg| { reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) + .with_remote_access() .source(outdoor_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempOutdoor.link_address().unwrap()) @@ -337,6 +372,7 @@ async fn main(spawner: Spawner) { builder.configure::(SensorKey::TempServerRoom, |reg| { reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) + .with_remote_access() .source(server_room_temp_producer) .tap(temperature_logger) .link_to(SensorKey::TempServerRoom.link_address().unwrap()) @@ -351,6 +387,7 @@ async fn main(spawner: Spawner) { builder.configure::(CommandKey::TempIndoor, |reg| { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) + .with_remote_access() .tap(command_consumer) .link_from(CommandKey::TempIndoor.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) @@ -359,6 +396,7 @@ async fn main(spawner: Spawner) { builder.configure::(CommandKey::TempOutdoor, |reg| { reg.buffer_sized::<8, 2>(EmbassyBufferType::SpmcRing) + .with_remote_access() .tap(command_consumer) .link_from(CommandKey::TempOutdoor.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| TemperatureCommand::from_json(data)) @@ -369,6 +407,10 @@ async fn main(spawner: Spawner) { info!(" OUTBOUND: sensors/temp/indoor, outdoor, server_room"); info!(" INBOUND: commands/temp/indoor, outdoor"); info!(" Broker: {}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + info!(" SERIAL (read-only AimX over USART3 / ST-LINK VCP):"); + info!( + " aimdb --features transport-serial --connect serial:///dev/ttyACM0?baud=115200 record list" + ); info!(""); info!( "Subscribe: mosquitto_sub -h {} -t 'sensors/#' -v", diff --git a/examples/knx-connector-demo-common/Cargo.toml b/examples/knx-connector-demo-common/Cargo.toml index 23a0ffa..54ff215 100644 --- a/examples/knx-connector-demo-common/Cargo.toml +++ b/examples/knx-connector-demo-common/Cargo.toml @@ -11,6 +11,9 @@ default = [] std = ["alloc", "aimdb-core/std", "aimdb-executor/std", "serde"] alloc = ["aimdb-core/alloc"] derive = ["aimdb-core/derive"] +# serde derive on the shared types — works in `no_std + alloc` too, so an +# embassy demo can expose these records over a remote-access (serial) server. +serde = ["dep:serde"] # Embassy-specific features (no_std) defmt = ["dep:defmt"] diff --git a/examples/knx-connector-demo-common/src/types.rs b/examples/knx-connector-demo-common/src/types.rs index 8608bd1..935ecfc 100644 --- a/examples/knx-connector-demo-common/src/types.rs +++ b/examples/knx-connector-demo-common/src/types.rs @@ -6,7 +6,7 @@ extern crate alloc; use alloc::string::String; -#[cfg(feature = "std")] +#[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; // ============================================================================ @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; /// Uses DPT 9.001 (2-byte floating point) for temperature values. /// The location field identifies which sensor this reading came from. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct TemperatureReading { /// Sensor location identifier (e.g., "Living Room", "Kitchen") pub location: String, @@ -52,7 +52,7 @@ impl TemperatureReading { /// /// Uses DPT 1.001 (1-bit boolean) for switch state. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct LightState { /// KNX group address this state came from (e.g., "1/0/7") pub group_address: String, @@ -83,7 +83,7 @@ impl LightState { /// /// Uses DPT 1.001 (1-bit boolean) for switch command. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct LightControl { /// Target KNX group address (e.g., "1/0/6") pub group_address: String, diff --git a/examples/mqtt-connector-demo-common/Cargo.toml b/examples/mqtt-connector-demo-common/Cargo.toml index 7366f05..0ee1d1f 100644 --- a/examples/mqtt-connector-demo-common/Cargo.toml +++ b/examples/mqtt-connector-demo-common/Cargo.toml @@ -11,6 +11,10 @@ default = [] std = ["alloc", "aimdb-core/std", "aimdb-executor/std", "serde", "serde_json"] alloc = ["aimdb-core/alloc"] derive = ["aimdb-core/derive"] +# serde derive on the shared types — works in `no_std + alloc` too (no serde_json +# needed: the no_std JSON paths are hand-rolled), so an embassy demo can expose +# these records over a remote-access (serial) server. +serde = ["dep:serde"] # Embassy-specific features (no_std) defmt = ["dep:defmt"] diff --git a/examples/mqtt-connector-demo-common/src/types.rs b/examples/mqtt-connector-demo-common/src/types.rs index 82cb692..a1c3fac 100644 --- a/examples/mqtt-connector-demo-common/src/types.rs +++ b/examples/mqtt-connector-demo-common/src/types.rs @@ -6,7 +6,7 @@ extern crate alloc; use alloc::string::String; -#[cfg(feature = "std")] +#[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; // ============================================================================ @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; /// /// Represents a temperature measurement that can be published to MQTT. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Temperature { /// Sensor identifier (e.g., "indoor-001", "outdoor-001") pub sensor_id: String, @@ -80,7 +80,7 @@ impl Temperature { /// Command for controlling temperature sensors (inbound from MQTT) #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct TemperatureCommand { /// Action to perform (e.g., "read", "calibrate", "reset") pub action: String, diff --git a/examples/tokio-knx-connector-demo/Cargo.toml b/examples/tokio-knx-connector-demo/Cargo.toml index 276297d..d82c4e2 100644 --- a/examples/tokio-knx-connector-demo/Cargo.toml +++ b/examples/tokio-knx-connector-demo/Cargo.toml @@ -32,6 +32,10 @@ aimdb-knx-connector = { path = "../../aimdb-knx-connector", features = [ "tracing", ] } +# UDS remote-access server — exposes this db to the `aimdb` CLI and aimdb-mcp +# over a Unix-domain socket (`unix:///tmp/aimdb-knx.sock`). +aimdb-uds-connector = { path = "../../aimdb-uds-connector" } + # Tokio runtime tokio = { workspace = true, features = ["full"] } diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index e6f1ce4..077c43e 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -22,9 +22,11 @@ //! Update the gateway URL and group addresses in `main()` to match your KNX setup. use aimdb_core::buffer::BufferCfg; +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::{AimDbBuilder, DbResult, Producer, RecordKey, RuntimeContext}; use aimdb_knx_connector::dpt::{Dpt1, Dpt9, DptDecode, DptEncode}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -82,13 +84,33 @@ async fn main() -> DbResult<()> { println!("Using shared types from knx-connector-demo-common"); println!("⚠️ Update gateway URL and group addresses to match your setup!\n"); - let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new("knx://192.168.1.4:3671"), - ); + // Expose this database over a Unix-domain socket as a *second* connector + // alongside KNX (the builder drives any number of them). A host can then + // introspect it live with the `aimdb` CLI or the aimdb-mcp server: + // + // aimdb --connect unix:///tmp/aimdb-knx.sock record list + // aimdb --connect unix:///tmp/aimdb-knx.sock record get temp.livingroom + // + // Read-only: KNX owns the writer for every record (single-writer-per-key), so + // remote `record.set` is disallowed — peers can list/get/subscribe, not write. + let socket_path = "/tmp/aimdb-knx.sock"; + let _ = std::fs::remove_file(socket_path); // clear a stale socket from a prior run + let remote_config = AimxConfig::uds_default() + .socket_path(socket_path) + .security_policy(SecurityPolicy::read_only()) + .max_connections(10); + + let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector(aimdb_knx_connector::KnxConnector::new( + "knx://192.168.1.4:3671", + )) + .with_connector(UdsServer::from_config(remote_config)); // Temperature sensors (inbound) - using link_address() from key metadata builder.configure::(TemperatureKey::LivingRoom, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::LivingRoom.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -100,6 +122,7 @@ async fn main() -> DbResult<()> { builder.configure::(TemperatureKey::Bedroom, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::Bedroom.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -111,6 +134,7 @@ async fn main() -> DbResult<()> { builder.configure::(TemperatureKey::Kitchen, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .tap(temperature_monitor) .link_from(TemperatureKey::Kitchen.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -123,6 +147,7 @@ async fn main() -> DbResult<()> { // Light monitors (inbound) - using link_address() from key metadata builder.configure::(LightKey::Main, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .tap(light_monitor) .link_from(LightKey::Main.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -134,6 +159,7 @@ async fn main() -> DbResult<()> { builder.configure::(LightKey::Hallway, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .tap(light_monitor) .link_from(LightKey::Hallway.link_address().unwrap()) .with_deserializer_raw(|data: &[u8]| { @@ -146,6 +172,7 @@ async fn main() -> DbResult<()> { // Light control (outbound) - using link_address() from key metadata builder.configure::(LightControlKey::Control, |reg| { reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() .source(input_handler) .link_to(LightControlKey::Control.link_address().unwrap()) .with_serializer_raw(|state: &LightControl| { @@ -157,6 +184,9 @@ async fn main() -> DbResult<()> { }); println!("Press ENTER to toggle light (1/0/6). Press Ctrl+C to stop.\n"); + println!("📡 Remote access (read-only) on {socket_path}"); + println!(" aimdb --connect unix://{socket_path} record list"); + println!(" aimdb --connect unix://{socket_path} watch temp.livingroom\n"); builder.run().await } diff --git a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml index 2d1ddef..9ec8f1d 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml @@ -89,10 +89,6 @@ embedded-alloc = { version = "0.6", features = ["llff"] } # Random number generation for simulation rand = { version = "0.10.1", default-features = false } -[profile.release] -opt-level = "s" -lto = true -debug = true [package.metadata.embassy] build = [ diff --git a/tools/aimdb-cli/CHANGELOG.md b/tools/aimdb-cli/CHANGELOG.md index 5c31360..93187a7 100644 --- a/tools/aimdb-cli/CHANGELOG.md +++ b/tools/aimdb-cli/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Global `--connect ` flag + `AIMDB_CONNECT` env (Issue #123).** Choose the target instance by `scheme://` URL — `unix://PATH`, `serial://DEVICE?baud=N` (with the `transport-serial` feature), or a bare path (the `unix://` shorthand). Precedence: `--connect` → `AIMDB_CONNECT` → UDS auto-discovery. `instance info`/`ping` now work over any endpoint (not just discovered sockets). New `transport-serial` feature (off by default; pulls libudev) adds the serial transport to the resolver. + +### Changed (breaking) + +- **Per-command `--socket` flags removed in favor of the global `--connect` (Issue #123).** `aimdb record/graph/watch/instance … --socket ` is gone; pass `--connect ` (a bare path still works) or rely on `AIMDB_CONNECT`/auto-discovery. `instance list` remains discovery-only. + ### Changed - **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** All commands (`watch`, `record`, `graph`) now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. `aimdb watch` subscribes via the engine, which streams updates routed by request id — there is no server-allocated subscription id to display, and `--queue-size` is accepted for compatibility but no longer meaningful (queue sizing is now an engine concern). diff --git a/tools/aimdb-cli/Cargo.toml b/tools/aimdb-cli/Cargo.toml index 124dba9..8fda0c5 100644 --- a/tools/aimdb-cli/Cargo.toml +++ b/tools/aimdb-cli/Cargo.toml @@ -56,6 +56,9 @@ serde_yaml = { version = "0.9", optional = true } [features] default = [] yaml = ["serde_yaml"] +# Add the serial transport to the endpoint resolver, so `--connect serial://…` +# works. Off by default (pulls tokio-serial → libudev on Linux). +transport-serial = ["aimdb-client/transport-serial"] [dev-dependencies] tokio-test = "0.4" diff --git a/tools/aimdb-cli/README.md b/tools/aimdb-cli/README.md index e4c5998..b5dc52b 100644 --- a/tools/aimdb-cli/README.md +++ b/tools/aimdb-cli/README.md @@ -4,7 +4,7 @@ Command-line interface for introspecting and managing running AimDB instances. ## Overview -The AimDB CLI is a thin client over the AimX v1 remote access protocol, providing intuitive commands for: +The AimDB CLI is a thin client over the AimX v2 remote access protocol, providing intuitive commands for: - Discovering running AimDB instances - Listing and inspecting records - Getting current record values @@ -96,6 +96,11 @@ aimdb record set server::Config '{"log_level":"debug","max_connections":100}' ## Command Reference +All commands accept a global `--connect ` flag to choose the target +instance (see [Connecting to an Instance](#connecting-to-an-instance)). When +omitted, the CLI reads the `AIMDB_CONNECT` env var, then falls back to UDS +auto-discovery. `instance list` is discovery-only and ignores `--connect`. + ### Instance Commands #### `instance list` @@ -109,20 +114,18 @@ Options: - `-f, --format `: Output format (table, json, json-compact, yaml) #### `instance info` -Show detailed information about a specific instance. +Show detailed information about a specific instance. Works over any endpoint, not +just discovered sockets. ```bash -aimdb instance info [--socket ] +aimdb [--connect ] instance info ``` -Options: -- `-s, --socket `: Socket path (uses auto-discovery if not specified) - #### `instance ping` Test connection to an instance. ```bash -aimdb instance ping [--socket ] +aimdb [--connect ] instance ping ``` ### Record Commands @@ -135,7 +138,6 @@ aimdb record list [OPTIONS] ``` Options: -- `-s, --socket `: Socket path (uses auto-discovery if not specified) - `-f, --format `: Output format (table, json, json-compact, yaml) - `-w, --writable`: Show only writable records @@ -150,7 +152,6 @@ Arguments: - ``: Record name (e.g., `server::Temperature`) Options: -- `-s, --socket `: Socket path - `-f, --format `: Output format (default: json) #### `record set` @@ -165,7 +166,6 @@ Arguments: - ``: JSON value to set Options: -- `-s, --socket `: Socket path - `--dry-run`: Validate but don't actually set **Note**: Only records without producers can be set remotely. @@ -183,7 +183,6 @@ Arguments: - ``: Record name to watch Options: -- `-s, --socket `: Socket path - `-q, --queue-size `: Subscription queue size (default: 100) - `-c, --count `: Maximum number of events to receive (0 = unlimited) - `-f, --full`: Show full pretty-printed JSON for each event @@ -202,7 +201,6 @@ aimdb graph nodes [OPTIONS] ``` Options: -- `-s, --socket `: Socket path (uses auto-discovery if not specified) - `-f, --format `: Output format (table, json, json-compact, yaml) Example output: @@ -224,7 +222,6 @@ aimdb graph edges [OPTIONS] ``` Options: -- `-s, --socket `: Socket path - `-f, --format `: Output format Example output: @@ -245,7 +242,6 @@ aimdb graph order [OPTIONS] ``` Options: -- `-s, --socket `: Socket path - `-f, --format `: Output format Example output: @@ -267,7 +263,6 @@ aimdb graph dot [OPTIONS] ``` Options: -- `-s, --socket `: Socket path - `-n, --name `: Graph name (default: "aimdb") Example: @@ -289,15 +284,57 @@ The CLI supports multiple output formats: - **json-compact**: Single-line JSON for scripting - **yaml**: YAML format (requires `yaml` feature) -## Socket Discovery +## Connecting to an Instance + +Pick the target instance with the global `--connect ` flag. The endpoint +is a `scheme://` URL — the scheme selects the transport at runtime, the same way +records pick one for links: + +| Endpoint | Transport | +|---|---| +| `unix:///tmp/aimdb.sock` / `uds:///tmp/aimdb.sock` | Unix domain socket | +| `/tmp/aimdb.sock` (bare path) | Unix domain socket (the `unix://` shorthand) | +| `serial:///dev/ttyACM0?baud=115200` | Serial/UART (requires the `transport-serial` build feature) | + +```bash +# Explicit endpoint +aimdb --connect unix:///tmp/aimdb.sock record list + +# Bare path shorthand +aimdb --connect /tmp/aimdb.sock record list + +# Serial board (CLI built with --features transport-serial) +aimdb --connect serial:///dev/ttyACM0?baud=115200 record list +``` + +An unknown scheme — or one whose transport isn't compiled into this binary — is +rejected with a clear error listing the built-in schemes. + +### Resolution order + +When `--connect` is omitted, the endpoint is resolved in this order: + +1. `--connect ` +2. `AIMDB_CONNECT` environment variable +3. UDS auto-discovery — scans `/tmp` and `/var/run/aimdb` for `.sock` files and + uses the first running instance. + +```bash +export AIMDB_CONNECT=unix:///tmp/aimdb.sock +aimdb record list # uses $AIMDB_CONNECT +``` + +`instance list` is always discovery-only and ignores `--connect`. -The CLI automatically discovers running AimDB instances by scanning: -- `/tmp` directory -- `/var/run/aimdb` directory +### Transport features -Socket files must have a `.sock` extension. +The Unix-socket transport is built in by default. The serial transport is +**off by default** (it pulls `tokio-serial` → libudev on Linux); enable it when +building: -You can override auto-discovery by specifying `--socket ` for any command. +```bash +cargo build --release -p aimdb-cli --features transport-serial +``` ## Error Handling @@ -389,7 +426,9 @@ aimdb graph nodes --format json | jq '.[] | select(.origin == "transform")' ## Protocol -The CLI uses the AimX v1 remote access protocol over Unix domain sockets with NDJSON message format. +The CLI uses the AimX v2 remote access protocol with NDJSON message format, over +whichever transport the `--connect` endpoint selects (Unix domain socket by +default; serial/UART with the `transport-serial` feature). See `docs/design/008-M3-remote-access.md` for the full protocol specification. diff --git a/tools/aimdb-cli/src/commands/graph.rs b/tools/aimdb-cli/src/commands/graph.rs index 4c6e2aa..baf503e 100644 --- a/tools/aimdb-cli/src/commands/graph.rs +++ b/tools/aimdb-cli/src/commands/graph.rs @@ -2,10 +2,9 @@ //! //! Commands for exploring the dependency graph of records in an AimDB instance. +use crate::commands::connect_endpoint; use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::discovery::find_instance; -use aimdb_client::AimxConnection; use clap::Args; use serde_json::Value; @@ -20,40 +19,24 @@ pub struct GraphCommand { pub enum GraphSubcommand { /// List all nodes in the dependency graph Nodes { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Output format #[arg(short, long, value_enum, default_value = "table")] format: OutputFormat, }, /// List all edges in the dependency graph Edges { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Output format #[arg(short, long, value_enum, default_value = "table")] format: OutputFormat, }, /// Show topological order of records Order { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Output format #[arg(short, long, value_enum, default_value = "table")] format: OutputFormat, }, /// Export graph in DOT format for visualization Dot { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Graph name for the DOT output #[arg(short, long, default_value = "aimdb")] name: String, @@ -61,25 +44,18 @@ pub enum GraphSubcommand { } impl GraphCommand { - pub async fn execute(self) -> CliResult<()> { + pub async fn execute(self, endpoint: Option<&str>) -> CliResult<()> { match self.subcommand { - GraphSubcommand::Nodes { socket, format } => { - list_nodes(socket.as_deref(), format).await - } - GraphSubcommand::Edges { socket, format } => { - list_edges(socket.as_deref(), format).await - } - GraphSubcommand::Order { socket, format } => { - show_topo_order(socket.as_deref(), format).await - } - GraphSubcommand::Dot { socket, name } => export_dot(socket.as_deref(), &name).await, + GraphSubcommand::Nodes { format } => list_nodes(endpoint, format).await, + GraphSubcommand::Edges { format } => list_edges(endpoint, format).await, + GraphSubcommand::Order { format } => show_topo_order(endpoint, format).await, + GraphSubcommand::Dot { name } => export_dot(endpoint, &name).await, } } } -async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; +async fn list_nodes(endpoint: Option<&str>, format: OutputFormat) -> CliResult<()> { + let client = connect_endpoint(endpoint).await?; let nodes = client.graph_nodes().await?; @@ -96,9 +72,8 @@ async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> Ok(()) } -async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; +async fn list_edges(endpoint: Option<&str>, format: OutputFormat) -> CliResult<()> { + let client = connect_endpoint(endpoint).await?; let edges = client.graph_edges().await?; @@ -115,9 +90,8 @@ async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> Ok(()) } -async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; +async fn show_topo_order(endpoint: Option<&str>, format: OutputFormat) -> CliResult<()> { + let client = connect_endpoint(endpoint).await?; let order = client.graph_topo_order().await?; @@ -134,9 +108,8 @@ async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResul Ok(()) } -async fn export_dot(socket: Option<&str>, name: &str) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; +async fn export_dot(endpoint: Option<&str>, name: &str) -> CliResult<()> { + let client = connect_endpoint(endpoint).await?; let nodes = client.graph_nodes().await?; let edges = client.graph_edges().await?; diff --git a/tools/aimdb-cli/src/commands/instance.rs b/tools/aimdb-cli/src/commands/instance.rs index c37eb4a..6de2f91 100644 --- a/tools/aimdb-cli/src/commands/instance.rs +++ b/tools/aimdb-cli/src/commands/instance.rs @@ -2,8 +2,10 @@ use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::discovery::{discover_instances, find_instance}; +use aimdb_client::discovery::{discover_instances, find_instance, InstanceInfo}; +use aimdb_client::AimxConnection; use clap::Args; +use std::path::PathBuf; /// Instance management commands #[derive(Debug, Args)] @@ -21,26 +23,34 @@ pub enum InstanceSubcommand { format: OutputFormat, }, /// Show detailed information about an instance - Info { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - }, + Info {}, /// Test connection to an instance - Ping { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - }, + Ping {}, } impl InstanceCommand { - pub async fn execute(self) -> CliResult<()> { + pub async fn execute(self, endpoint: Option<&str>) -> CliResult<()> { match self.subcommand { + // `list` is discovery-only (a Unix-socket scan); it ignores --connect. InstanceSubcommand::List { format } => list_instances(format).await, - InstanceSubcommand::Info { socket } => show_instance_info(socket.as_deref()).await, - InstanceSubcommand::Ping { socket } => ping_instance(socket.as_deref()).await, + InstanceSubcommand::Info {} => show_instance_info(endpoint).await, + InstanceSubcommand::Ping {} => ping_instance(endpoint).await, + } + } +} + +/// Resolve an instance's info: an explicit endpoint connects and reads the +/// server's Welcome; `None` falls back to UDS auto-discovery. +async fn resolve_instance(endpoint: Option<&str>) -> CliResult { + match endpoint { + Some(ep) => { + let conn = AimxConnection::connect(ep).await?; + Ok(InstanceInfo::from(( + PathBuf::from(ep), + conn.server_info().clone(), + ))) } + None => Ok(find_instance(None).await?), } } @@ -60,18 +70,18 @@ async fn list_instances(format: OutputFormat) -> CliResult<()> { Ok(()) } -async fn show_instance_info(socket: Option<&str>) -> CliResult<()> { - let instance = find_instance(socket).await?; +async fn show_instance_info(endpoint: Option<&str>) -> CliResult<()> { + let instance = resolve_instance(endpoint).await?; let output = table::format_instance_info(&instance); println!("{}", output); Ok(()) } -async fn ping_instance(socket: Option<&str>) -> CliResult<()> { - let instance = find_instance(socket).await?; +async fn ping_instance(endpoint: Option<&str>) -> CliResult<()> { + let instance = resolve_instance(endpoint).await?; println!("✅ Connection successful!"); - println!(" Socket: {}", instance.socket_path.display()); + println!(" Endpoint: {}", instance.endpoint.display()); println!(" Server: {}", instance.server_version); println!(" Protocol: {}", instance.protocol_version); diff --git a/tools/aimdb-cli/src/commands/mod.rs b/tools/aimdb-cli/src/commands/mod.rs index 39b196e..dfb501b 100644 --- a/tools/aimdb-cli/src/commands/mod.rs +++ b/tools/aimdb-cli/src/commands/mod.rs @@ -7,3 +7,22 @@ pub mod graph; pub mod instance; pub mod record; pub mod watch; + +use aimdb_client::AimxConnection; + +use crate::error::CliResult; + +/// Resolve an `endpoint` to a live connection. +/// +/// An explicit endpoint (`--connect`, or the `AIMDB_CONNECT` env) dials directly — +/// any `scheme://` URL, or a bare path as the `unix://` shorthand. `None` falls +/// back to UDS auto-discovery (the first running instance found). +pub(crate) async fn connect_endpoint(endpoint: Option<&str>) -> CliResult { + match endpoint { + Some(ep) => Ok(AimxConnection::connect(ep).await?), + None => { + let instance = aimdb_client::discovery::find_instance(None).await?; + Ok(AimxConnection::connect(&instance.endpoint.to_string_lossy()).await?) + } + } +} diff --git a/tools/aimdb-cli/src/commands/record.rs b/tools/aimdb-cli/src/commands/record.rs index d83507c..5362bad 100644 --- a/tools/aimdb-cli/src/commands/record.rs +++ b/tools/aimdb-cli/src/commands/record.rs @@ -1,9 +1,8 @@ //! Record Management Commands +use crate::commands::connect_endpoint; use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::discovery::find_instance; -use aimdb_client::AimxConnection; use clap::Args; /// Record management commands @@ -17,10 +16,6 @@ pub struct RecordCommand { pub enum RecordSubcommand { /// List all registered records List { - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Output format #[arg(short, long, value_enum, default_value = "table")] format: OutputFormat, @@ -34,10 +29,6 @@ pub enum RecordSubcommand { /// Record name record: String, - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Output format #[arg(short, long, value_enum, default_value = "json")] format: OutputFormat, @@ -50,10 +41,6 @@ pub enum RecordSubcommand { /// JSON value to set value: String, - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - socket: Option, - /// Dry run - validate but don't actually set #[arg(long)] dry_run: bool, @@ -61,35 +48,27 @@ pub enum RecordSubcommand { } impl RecordCommand { - pub async fn execute(self) -> CliResult<()> { + pub async fn execute(self, endpoint: Option<&str>) -> CliResult<()> { match self.subcommand { - RecordSubcommand::List { - socket, - format, - writable, - } => list_records(socket.as_deref(), format, writable).await, - RecordSubcommand::Get { - record, - socket, - format, - } => get_record(&record, socket.as_deref(), format).await, + RecordSubcommand::List { format, writable } => { + list_records(endpoint, format, writable).await + } + RecordSubcommand::Get { record, format } => get_record(&record, endpoint, format).await, RecordSubcommand::Set { name, value, - socket, dry_run, - } => set_record(&name, &value, socket.as_deref(), dry_run).await, + } => set_record(&name, &value, endpoint, dry_run).await, } } } async fn list_records( - socket: Option<&str>, + endpoint: Option<&str>, format: OutputFormat, writable_only: bool, ) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; + let client = connect_endpoint(endpoint).await?; let mut records = client.list_records().await?; @@ -111,9 +90,8 @@ async fn list_records( Ok(()) } -async fn get_record(name: &str, socket: Option<&str>, format: OutputFormat) -> CliResult<()> { - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; +async fn get_record(name: &str, endpoint: Option<&str>, format: OutputFormat) -> CliResult<()> { + let client = connect_endpoint(endpoint).await?; let value = client.get_record(name).await?; @@ -136,7 +114,7 @@ async fn get_record(name: &str, socket: Option<&str>, format: OutputFormat) -> C async fn set_record( name: &str, value_str: &str, - socket: Option<&str>, + endpoint: Option<&str>, dry_run: bool, ) -> CliResult<()> { // Parse JSON value @@ -150,8 +128,7 @@ async fn set_record( return Ok(()); } - let instance = find_instance(socket).await?; - let client = AimxConnection::connect(&instance.socket_path).await?; + let client = connect_endpoint(endpoint).await?; let result = client.set_record(name, value).await?; diff --git a/tools/aimdb-cli/src/commands/watch.rs b/tools/aimdb-cli/src/commands/watch.rs index 465f573..d1b7a7d 100644 --- a/tools/aimdb-cli/src/commands/watch.rs +++ b/tools/aimdb-cli/src/commands/watch.rs @@ -1,9 +1,8 @@ //! Watch Command - Live Record Monitoring +use crate::commands::connect_endpoint; use crate::error::CliResult; use crate::output::live; -use aimdb_client::discovery::find_instance; -use aimdb_client::AimxConnection; use clap::Args; use futures::StreamExt; use tokio::signal; @@ -14,10 +13,6 @@ pub struct WatchCommand { /// Record name to watch pub record: String, - /// Socket path (optional, uses auto-discovery if not specified) - #[arg(short, long)] - pub socket: Option, - /// Queue size for subscription #[arg(short, long, default_value = "100")] pub queue_size: usize, @@ -32,10 +27,10 @@ pub struct WatchCommand { } impl WatchCommand { - pub async fn execute(self) -> CliResult<()> { + pub async fn execute(self, endpoint: Option<&str>) -> CliResult<()> { watch_record( &self.record, - self.socket.as_deref(), + endpoint, self.queue_size, self.count, self.full, @@ -46,14 +41,13 @@ impl WatchCommand { async fn watch_record( record_name: &str, - socket: Option<&str>, + endpoint: Option<&str>, queue_size: usize, max_count: usize, show_full: bool, ) -> CliResult<()> { let _ = queue_size; // queue sizing is now an engine concern; kept as a CLI flag - let instance = find_instance(socket).await?; - let conn = AimxConnection::connect(&instance.socket_path).await?; + let conn = connect_endpoint(endpoint).await?; // Subscribe to the record (the engine routes updates back by request id; no // server-allocated subscription id to track). diff --git a/tools/aimdb-cli/src/error.rs b/tools/aimdb-cli/src/error.rs index 7ef1321..716f96b 100644 --- a/tools/aimdb-cli/src/error.rs +++ b/tools/aimdb-cli/src/error.rs @@ -13,8 +13,12 @@ pub type CliResult = Result; #[allow(dead_code)] // Some variants are for future use pub enum CliError { /// Failed to connect to AimDB instance - #[error("Connection failed: {socket}\n Reason: {reason}\n Hint: Check if AimDB instance is running")] - ConnectionFailed { socket: String, reason: String }, + #[error("Connection failed: {endpoint}\n Reason: {reason}\n Hint: Check if AimDB instance is running")] + ConnectionFailed { endpoint: String, reason: String }, + + /// Endpoint string was malformed or named an unsupported/not-built-in scheme + #[error("Unsupported endpoint: {endpoint}\n Reason: {reason}\n Hint: use unix://PATH, serial://DEVICE?baud=N, or a bare path")] + UnsupportedEndpoint { endpoint: String, reason: String }, /// No running AimDB instances found #[error("No AimDB instances found\n Searched: /tmp, /var/run/aimdb\n Hint: Start an AimDB application that registers a remote-access server (e.g. UdsServer via .with_connector(...))")] @@ -65,8 +69,11 @@ impl From for CliError { fn from(err: aimdb_client::ClientError) -> Self { match err { aimdb_client::ClientError::NoInstancesFound => CliError::NoInstancesFound, - aimdb_client::ClientError::ConnectionFailed { socket, reason } => { - CliError::ConnectionFailed { socket, reason } + aimdb_client::ClientError::ConnectionFailed { endpoint, reason } => { + CliError::ConnectionFailed { endpoint, reason } + } + aimdb_client::ClientError::UnsupportedEndpoint { endpoint, reason } => { + CliError::UnsupportedEndpoint { endpoint, reason } } aimdb_client::ClientError::ServerError { code, @@ -94,9 +101,9 @@ fn format_details(details: &Option) -> String { #[allow(dead_code)] // Helper methods for future use impl CliError { /// Create a connection failed error - pub fn connection_failed(socket: impl Into, reason: impl Into) -> Self { + pub fn connection_failed(endpoint: impl Into, reason: impl Into) -> Self { Self::ConnectionFailed { - socket: socket.into(), + endpoint: endpoint.into(), reason: reason.into(), } } diff --git a/tools/aimdb-cli/src/main.rs b/tools/aimdb-cli/src/main.rs index fa718d3..5a5a031 100644 --- a/tools/aimdb-cli/src/main.rs +++ b/tools/aimdb-cli/src/main.rs @@ -21,6 +21,12 @@ struct Cli { #[command(subcommand)] command: Command, + /// Endpoint of the AimDB instance: a `scheme://` URL (`unix://PATH`, + /// `serial://DEVICE?baud=N`) or a bare path (the `unix://` shorthand). + /// Falls back to the `AIMDB_CONNECT` env var, then auto-discovery. + #[arg(long, global = true)] + connect: Option, + /// Enable verbose output #[arg(short, long, global = true)] verbose: bool, @@ -53,11 +59,18 @@ enum Command { async fn main() { let cli = Cli::parse(); + // Endpoint precedence: --connect, then AIMDB_CONNECT, else auto-discovery (None). + let endpoint = cli + .connect + .or_else(|| std::env::var("AIMDB_CONNECT").ok()) + .filter(|s| !s.is_empty()); + let endpoint = endpoint.as_deref(); + let result = match cli.command { - Command::Instance(cmd) => cmd.execute().await, - Command::Record(cmd) => cmd.execute().await, - Command::Graph(cmd) => cmd.execute().await, - Command::Watch(cmd) => cmd.execute().await, + Command::Instance(cmd) => cmd.execute(endpoint).await, + Command::Record(cmd) => cmd.execute(endpoint).await, + Command::Graph(cmd) => cmd.execute(endpoint).await, + Command::Watch(cmd) => cmd.execute(endpoint).await, Command::Generate(cmd) => cmd.execute().await, }; diff --git a/tools/aimdb-cli/src/output/json.rs b/tools/aimdb-cli/src/output/json.rs index 38735e2..7418969 100644 --- a/tools/aimdb-cli/src/output/json.rs +++ b/tools/aimdb-cli/src/output/json.rs @@ -24,7 +24,7 @@ pub fn format_instances_json( .iter() .map(|i| { serde_json::json!({ - "socket_path": i.socket_path.display().to_string(), + "socket_path": i.endpoint.display().to_string(), "server_version": i.server_version, "protocol_version": i.protocol_version, "permissions": i.permissions, diff --git a/tools/aimdb-cli/src/output/table.rs b/tools/aimdb-cli/src/output/table.rs index 6e58cec..8d3ac2d 100644 --- a/tools/aimdb-cli/src/output/table.rs +++ b/tools/aimdb-cli/src/output/table.rs @@ -26,7 +26,7 @@ pub fn format_instances_table(instances: &[InstanceInfo]) -> String { // Add rows for instance in instances { builder.push_record(vec![ - instance.socket_path.display().to_string(), + instance.endpoint.display().to_string(), instance.server_version.clone(), instance.protocol_version.clone(), instance.permissions.len().to_string(), @@ -82,7 +82,7 @@ pub fn format_instance_info(instance: &InstanceInfo) -> String { let mut output = String::new(); output.push_str(&format!("{}\n", "Instance Information".bold())); - output.push_str(&format!(" Socket: {}\n", instance.socket_path.display())); + output.push_str(&format!(" Endpoint: {}\n", instance.endpoint.display())); output.push_str(&format!(" Server: {}\n", instance.server_version)); output.push_str(&format!(" Protocol: {}\n", instance.protocol_version)); output.push_str(&format!( diff --git a/tools/aimdb-mcp/CHANGELOG.md b/tools/aimdb-mcp/CHANGELOG.md index 70db5f6..3cdb7fc 100644 --- a/tools/aimdb-mcp/CHANGELOG.md +++ b/tools/aimdb-mcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **Tools take an `endpoint` (a `scheme://` URL), not a `socket_path` (Issue #123).** Every tool's `socket_path` parameter is renamed `endpoint` and now accepts any endpoint URL — `unix://PATH`, `serial://DEVICE?baud=N` (with the `transport-serial` feature), or a bare path (the `unix://` shorthand). The startup `--socket ` flag is now `--connect `, and the `AIMDB_SOCKET` env var is now `AIMDB_CONNECT` (resolution order: explicit `endpoint` → `--connect` → `AIMDB_CONNECT`). The connection pool is keyed by endpoint URL, and public-mode SSRF stripping now strips `endpoint`. `get_instance_info`'s result field `socket_path` is renamed `endpoint`; `discover_instances` (Unix-socket discovery) keeps `socket_path`. New `transport-serial` feature (off by default; pulls libudev) adds the serial transport to the resolver. + ### Changed - **`list_records`, the `records` resource, and `query_schema` no longer emit `created_at` / `last_update` (Issue #120).** `aimdb-core` dropped per-record timestamp tracking from `RecordMetadata` (it kept no wall-clock state for the `no_std` AimX server), so the AimX `record.list` payload — and these MCP outputs derived from it — no longer carry those two fields. The `RecordInfo` struct drops them too. Every other record-metadata field is unchanged. diff --git a/tools/aimdb-mcp/Cargo.toml b/tools/aimdb-mcp/Cargo.toml index edbc568..113449f 100644 --- a/tools/aimdb-mcp/Cargo.toml +++ b/tools/aimdb-mcp/Cargo.toml @@ -24,6 +24,12 @@ all-features = true name = "aimdb_mcp" path = "src/lib.rs" +[features] +default = [] +# Add the serial transport to the endpoint resolver, so an `endpoint` of +# `serial://…` works. Off by default (pulls tokio-serial → libudev on Linux). +transport-serial = ["aimdb-client/transport-serial"] + [dependencies] # AimDB dependencies aimdb-client = { version = "0.6.0", path = "../../aimdb-client", features = [ diff --git a/tools/aimdb-mcp/README.md b/tools/aimdb-mcp/README.md index 9d0bb0a..240405e 100644 --- a/tools/aimdb-mcp/README.md +++ b/tools/aimdb-mcp/README.md @@ -10,8 +10,8 @@ Model Context Protocol (MCP) server for AimDB - enables LLM-powered introspectio - **LLM-Powered**: Natural language queries to AimDB instances - **Auto-Discovery**: Automatically finds running AimDB servers - **Schema Inference**: Infers JSON schemas from record values -- **Live Subscriptions**: Subscribe to record updates with automatic data capture -- **Rich Toolset**: 11 tools covering all AimDB operations +- **Architecture Agent**: Propose, validate, and apply schema/topology changes from natural language +- **Rich Toolset**: 30 tools covering introspection, record ops, the dependency graph, metrics/profiling, and architecture editing - **VS Code Integration**: Works seamlessly with GitHub Copilot ## Architecture @@ -87,6 +87,59 @@ Add to `claude_desktop_config.json`: } ``` +### Choosing the Target Instance + +Every tool that talks to an instance takes an optional `endpoint` argument — a +`scheme://` URL that selects the transport at runtime: + +| Endpoint | Transport | +|---|---| +| `unix:///tmp/aimdb.sock` / `uds:///tmp/aimdb.sock` | Unix domain socket | +| `/tmp/aimdb.sock` (bare path) | Unix domain socket (the `unix://` shorthand) | +| `serial:///dev/ttyACM0?baud=115200` | Serial/UART (requires the `transport-serial` build feature) | + +When a tool's `endpoint` is omitted, the server resolves it in this order: + +1. The tool's explicit `endpoint` argument +2. The `--connect ` startup flag +3. The `AIMDB_CONNECT` environment variable + +This lets you pin a single instance once at startup so the LLM never has to pass a +path. Pass it as a flag: + +```json +{ + "mcpServers": { + "aimdb": { + "command": "/path/to/aimdb-mcp", + "args": ["--connect", "unix:///tmp/aimdb-demo.sock"] + } + } +} +``` + +…or via the environment: + +```json +{ + "mcpServers": { + "aimdb": { + "command": "/path/to/aimdb-mcp", + "args": [], + "env": { "AIMDB_CONNECT": "unix:///tmp/aimdb-demo.sock" } + } + } +} +``` + +In `--public` mode any client-supplied `endpoint` is stripped, so tools fall back +to the server-pinned `--connect` / `AIMDB_CONNECT` and clients cannot probe +arbitrary paths on the host. + +The serial transport is **off by default** (it pulls `tokio-serial` → libudev on +Linux); build with `cargo build -p aimdb-mcp --features transport-serial` to dial +`serial://` endpoints. + ### Test Server Start the example server: @@ -106,7 +159,7 @@ This creates an instance at `/tmp/aimdb-demo.sock` with sample records. 1. Starting demo server 2. Asking Copilot "What AimDB instances are running?" 3. Asking "What's the current temperature?" - 4. Subscribing to updates + 4. Draining a record for trend analysis Suggested location: assets/aimdb-mcp-demo.gif Usage: ![AimDB MCP in Action](../../assets/aimdb-mcp-demo.gif) @@ -114,6 +167,11 @@ This creates an instance at `/tmp/aimdb-demo.sock` with sample records. ## Available Tools +30 tools in total: 14 introspection & operations tools (below) and 16 +architecture-agent tools (see [Architecture Agent Tools](#architecture-agent-tools)). +In `--public` mode only `discover_instances`, `list_records`, and `get_record` are +advertised. + ### 1. discover_instances Find all running AimDB instances: @@ -174,44 +232,44 @@ Query: "What's the schema of the Temperature record?" Result: JSON Schema with types, required fields, and example ``` -### 7. subscribe_record +### 7. drain_record -Subscribe to live record updates: +Drain all values accumulated since the last drain (a destructive batch read): ``` -Query: "Subscribe to temperature for 50 samples" +Query: "Drain the temperature buffer and analyze the trend" -Action: Creates subscription, auto-saves updates to JSONL file +Result: Values in chronological order; the first call is a cold start (empty) ``` -### 8. unsubscribe_record +### 8. graph_nodes -Stop an active subscription: +List all nodes in the dependency graph: ``` -Query: "Stop subscription sub-abc123" +Query: "Show me the record graph nodes" -Action: Unsubscribes and stops data collection +Result: Per-record origin (source/link/transform/passive), buffer config, and edge counts ``` -### 9. list_subscriptions +### 9. graph_edges -Show active subscriptions: +List the directed edges (data flow) between records: ``` -Query: "What subscriptions are active?" +Query: "How does data flow between records?" -Result: Subscription IDs, records, sample counts, file paths +Result: Directed edges from sources through transforms to consumers ``` -### 10. get_notification_directory +### 10. graph_topo_order -Get directory where subscription data is saved: +Show the topological (spawn/initialization) order of records: ``` -Query: "Where is subscription data saved?" +Query: "What order are records initialized in?" -Result: Path to notification directory +Result: Record keys ordered so dependencies precede their dependents ``` ### 11. get_stage_profiling @@ -267,6 +325,36 @@ Query: "Reset the buffer metrics counters." Result: { "reset": true } ``` +## Architecture Agent Tools + +Beyond live introspection, the server exposes an **architecture agent** for +designing and editing an AimDB topology from natural language. It reads/writes +`.aimdb/state.toml` and, on confirmation, generates Mermaid and Rust artefacts. +These tools are not available in `--public` mode. + +The editing tools follow a **propose → resolve** flow: a `propose_*` / +`remove_*` / `rename_record` call creates a *pending proposal* (shown to the user), +which `resolve_proposal` then confirms, rejects, or revises. + +| Tool | Purpose | +|---|---| +| `get_architecture` | Return current state from `.aimdb/state.toml` (record count, validation summary, decision-log length). Run first when starting a session. | +| `validate_against_instance` | Compare `state.toml` to a live instance; report missing records, buffer/capacity mismatches, and connector diffs. | +| `propose_add_record` | Propose a new record (explicit, typed fields). | +| `propose_modify_buffer` | Propose changing a record's buffer type / capacity. | +| `propose_add_connector` | Propose adding a connector (MQTT, KNX, …) to a record. | +| `propose_modify_fields` | Propose replacing a record's value-struct fields (all fields). | +| `propose_modify_key_variants` | Propose updating a record's key variants. | +| `propose_add_task` | Propose a new task definition. | +| `propose_add_binary` | Propose a new binary definition. | +| `remove_task` | Propose removing a task. | +| `remove_binary` | Propose removing a binary (task definitions are preserved). | +| `remove_record` | Propose removing a record. | +| `rename_record` | Propose renaming a record (renames the generated key enum + value struct). | +| `resolve_proposal` | Confirm / reject / revise a pending proposal; on confirm writes `state.toml` and regenerates artefacts. | +| `save_memory` | Persist ideation context and rationale to `.aimdb/memory.md`. | +| `reset_session` | Discard pending proposals and start over. | + ## Schema Inference The MCP server can infer JSON schemas from record values: @@ -297,73 +385,32 @@ The MCP server can infer JSON schemas from record values: - Nullable fields require multiple samples - Ask user for clarification on ambiguous cases -## Subscriptions - -### How It Works - -1. LLM requests subscription with sample limit -2. Server creates subscription and JSONL file -3. Updates automatically saved as they arrive -4. Auto-unsubscribes when limit reached -5. File path returned for analysis - -### File Format - -Subscription data saved as JSONL (one JSON object per line): - -```jsonl -{"timestamp":"2025-11-06T10:30:45.123Z","sequence_number":1,"value":{"celsius":23.5,"sensor_id":"sensor-001"}} -{"timestamp":"2025-11-06T10:30:47.456Z","sequence_number":2,"value":{"celsius":23.6,"sensor_id":"sensor-001"}} -{"timestamp":"2025-11-06T10:30:49.789Z","sequence_number":3,"value":{"celsius":23.7,"sensor_id":"sensor-001"}} -``` - -### Sample Limits - -**IMPORTANT:** Always ask user for sample limit before subscribing. - -Suggested limits: -- **10-30 samples**: Quick check (~20-60 seconds) -- **50-100 samples**: Short monitoring (~2-3 minutes) -- **200-500 samples**: Extended analysis (~7-17 minutes) -- **null**: Unlimited (requires explicit user confirmation) - -### File Location - -Default: `~/.local/share/aimdb-mcp/notifications/` - -Files named: `{subscription_id}.jsonl` - ## Resources -MCP server provides 5 resources: - -### 1. `aimdb://instances` -List of all discovered instances +The server exposes two families of resources. Instance resources are discovered +by scanning for Unix sockets, so their URIs are keyed by socket path: -### 2. `aimdb://instance/{socket_path}` -Details about specific instance +- `aimdb://instances` — list of all discovered instances +- `aimdb://instance/{socket_path}` — details about a specific instance +- `aimdb://{socket_path}/records` — all records in an instance -### 3. `aimdb://records/{socket_path}` -All records in an instance +Architecture resources expose the `.aimdb/` design state used by the architecture +agent: -### 4. `aimdb://record/{socket_path}/{record_name}` -Specific record value - -### 5. `aimdb://schema/{socket_path}/{record_name}` -Inferred schema for record +- `aimdb://architecture` — architecture overview +- `aimdb://architecture/state` — full `state.toml` as JSON +- `aimdb://architecture/conflicts` — conflicts vs. a live instance +- `aimdb://architecture/conventions` — naming/design conventions +- `aimdb://architecture/memory` — persisted ideation context (`memory.md`) ## Prompts -MCP server provides 3 helper prompts: - -### 1. `aimdb-quickstart` -Introduction and common usage patterns - -### 2. `notification-directory` -Information about subscription data storage +The server provides 4 helper prompts (not available in `--public` mode): -### 3. `subscription-help` -Guide to subscriptions and data analysis +- `architecture_agent` — drive the propose → resolve design workflow +- `onboarding` — introduction and common usage patterns +- `breaking_change_review` — review the impact of a proposed schema change +- `troubleshooting` — guided diagnostics for connection/record issues ## Protocol Details @@ -372,9 +419,9 @@ Guide to subscriptions and data analysis - **Format**: NDJSON (newline-delimited JSON) ### Capabilities -- **Tools**: ✓ (11 tools) -- **Resources**: ✓ (5 resources) -- **Prompts**: ✓ (3 prompts) +- **Tools**: ✓ (30 tools; only 3 advertised in `--public` mode) +- **Resources**: ✓ (instances + architecture families) +- **Prompts**: ✓ (4 prompts) - **Sampling**: ✗ (not supported) - **Logging**: ✓ (stderr) @@ -422,14 +469,13 @@ LLM: ### Data Monitoring ``` -User: "Monitor temperature for 100 samples and analyze" +User: "Monitor temperature and analyze the trend" LLM: -1. subscribe_record(server::Temperature, 100) -2. Waits for completion -3. Reads JSONL file -4. Analyzes: min, max, avg, trends, anomalies -5. Generates report +1. drain_record(server::Temperature) # cold start — creates the reader, returns empty +2. drain_record(server::Temperature) # later: returns values accumulated since last drain +3. Analyzes: min, max, avg, trends, anomalies +4. Generates report ``` ### Configuration Update @@ -520,20 +566,20 @@ pub fn my_new_tool() -> Tool { - Future: Token-based auth planned ### Permissions -- Read-only by default (list, get, subscribe) +- Read-only by default (list, get, drain, graph) - Write operations (set) require writable records +- Architecture-editing tools are disabled in `--public` mode - No shell access or arbitrary code execution ### Data Privacy -- Subscription data stored locally +- Architecture state (`.aimdb/`) is stored locally - No network communication - All data stays on local machine ## Performance - **Tool latency**: < 10ms for local operations -- **Subscription overhead**: Minimal (async streaming) -- **Memory usage**: ~5MB base + subscription buffers +- **Memory usage**: ~5MB base - **Concurrent connections**: Single-threaded stdio ## Troubleshooting @@ -562,18 +608,6 @@ ls /tmp/*.sock /var/run/aimdb/*.sock stat /tmp/aimdb-demo.sock ``` -### Subscription not working - -1. Check notification directory: -```bash -ls -la ~/.local/share/aimdb-mcp/notifications/ -``` - -2. Verify disk space: -```bash -df -h ~/.local/share/ -``` - ## References - **MCP Specification**: https://spec.modelcontextprotocol.io/ diff --git a/tools/aimdb-mcp/src/connection.rs b/tools/aimdb-mcp/src/connection.rs index 0e80025..ce35ea0 100644 --- a/tools/aimdb-mcp/src/connection.rs +++ b/tools/aimdb-mcp/src/connection.rs @@ -23,7 +23,7 @@ pub struct ConnectionPool { /// Track which connections we've attempted (for logging/metrics) connections: Arc>>, /// Persistent drain clients — kept alive so drain readers accumulate values - /// Key: socket_path, Value: shared AimxConnection + /// Key: endpoint, Value: shared AimxConnection drain_clients: Arc>>>>, } @@ -51,33 +51,33 @@ impl ConnectionPool { /// connection each time. The pool tracks connection metadata for /// monitoring and future optimization (e.g., persistent connections /// via Arc> if AimxConnection becomes Sync). - pub async fn get_connection(&self, socket_path: &str) -> Result { + pub async fn get_connection(&self, endpoint: &str) -> Result { let mut pool = self.connections.lock().await; // Update or insert connection metadata let now = std::time::Instant::now(); - if let Some(entry) = pool.get_mut(socket_path) { + if let Some(entry) = pool.get_mut(endpoint) { debug!( "♻️ Connection metadata exists for {}, reconnecting", - socket_path + endpoint ); entry.last_used = now; } else { - debug!("🔌 First connection to {}", socket_path); - pool.insert(socket_path.to_string(), ConnectionEntry { last_used: now }); + debug!("🔌 First connection to {}", endpoint); + pool.insert(endpoint.to_string(), ConnectionEntry { last_used: now }); } // Always create a new connection (until AimxConnection supports cloning/sharing) drop(pool); // Release lock before async operation - AimxConnection::connect(socket_path).await + AimxConnection::connect(endpoint).await } /// Remove a connection from the pool (called when operations fail) - pub async fn invalidate_connection(&self, socket_path: &str) { + pub async fn invalidate_connection(&self, endpoint: &str) { let mut pool = self.connections.lock().await; - if pool.remove(socket_path).is_some() { - debug!("❌ Invalidated connection metadata for {}", socket_path); + if pool.remove(endpoint).is_some() { + debug!("❌ Invalidated connection metadata for {}", endpoint); } } @@ -89,37 +89,37 @@ impl ConnectionPool { /// return all values accumulated since the previous drain. pub async fn get_drain_client( &self, - socket_path: &str, + endpoint: &str, ) -> Result>, ClientError> { let drain_map = self.drain_clients.lock().await; - if let Some(client) = drain_map.get(socket_path) { - debug!("♻️ Reusing persistent drain client for {}", socket_path); + if let Some(client) = drain_map.get(endpoint) { + debug!("♻️ Reusing persistent drain client for {}", endpoint); return Ok(Arc::clone(client)); } - debug!("🔌 Creating persistent drain client for {}", socket_path); + debug!("🔌 Creating persistent drain client for {}", endpoint); // Drop lock before async connect drop(drain_map); - let client = AimxConnection::connect(socket_path).await?; + let client = AimxConnection::connect(endpoint).await?; let shared = Arc::new(tokio::sync::Mutex::new(client)); let mut drain_map = self.drain_clients.lock().await; // Double-check: another task may have inserted while we were connecting - if let Some(existing) = drain_map.get(socket_path) { + if let Some(existing) = drain_map.get(endpoint) { return Ok(Arc::clone(existing)); } - drain_map.insert(socket_path.to_string(), Arc::clone(&shared)); + drain_map.insert(endpoint.to_string(), Arc::clone(&shared)); Ok(shared) } /// Invalidate (remove) a persistent drain client, e.g. after connection error - pub async fn invalidate_drain_client(&self, socket_path: &str) { + pub async fn invalidate_drain_client(&self, endpoint: &str) { let mut drain_map = self.drain_clients.lock().await; - if drain_map.remove(socket_path).is_some() { - debug!("❌ Invalidated drain client for {}", socket_path); + if drain_map.remove(endpoint).is_some() { + debug!("❌ Invalidated drain client for {}", endpoint); } } diff --git a/tools/aimdb-mcp/src/main.rs b/tools/aimdb-mcp/src/main.rs index abee943..ee257f4 100644 --- a/tools/aimdb-mcp/src/main.rs +++ b/tools/aimdb-mcp/src/main.rs @@ -22,19 +22,20 @@ struct Cli { #[arg(long)] public: bool, - /// Default Unix socket path for AimDB connections. - /// Tools will use this instead of requiring an explicit socket_path argument. - /// Equivalent to setting the AIMDB_SOCKET environment variable. - #[arg(long, value_name = "PATH")] - socket: Option, + /// Default endpoint for AimDB connections: a `scheme://` URL (`unix://PATH`, + /// `serial://DEVICE?baud=N`) or a bare path. Tools use this instead of + /// requiring an explicit `endpoint` argument. Equivalent to setting the + /// AIMDB_CONNECT environment variable. + #[arg(long, value_name = "ENDPOINT")] + connect: Option, } #[tokio::main] async fn main() { let cli = Cli::parse(); - if let Some(ref socket) = cli.socket { - aimdb_mcp::tools::set_default_socket(socket.clone()); + if let Some(ref endpoint) = cli.connect { + aimdb_mcp::tools::set_default_endpoint(endpoint.clone()); } // Initialize tracing @@ -52,8 +53,8 @@ async fn main() { if cli.public { info!("🔒 Public mode: only read-only tools are available"); } - if let Some(ref socket) = cli.socket { - info!("📎 Default socket: {}", socket); + if let Some(ref endpoint) = cli.connect { + info!("📎 Default endpoint: {}", endpoint); } // Create server and transport @@ -115,6 +116,16 @@ async fn process_request( debug!("🎯 Method: {}, ID: {:?}", method, request_id); + // A JSON-RPC notification has no `id` and MUST NOT receive a response — e.g. + // the client's `notifications/initialized` after the handshake. Acknowledge by + // ignoring it; replying (even with an error) violates the spec and trips strict + // clients. The server is already Ready post-`initialize`, so none of the + // notifications we currently receive need server-side handling. + if request.id.is_none() { + debug!("Ignoring notification: {}", method); + return Ok(()); + } + // Dispatch to handlers let response_value = match method.as_str() { "initialize" => { diff --git a/tools/aimdb-mcp/src/resources/instances.rs b/tools/aimdb-mcp/src/resources/instances.rs index ca14564..36b35fb 100644 --- a/tools/aimdb-mcp/src/resources/instances.rs +++ b/tools/aimdb-mcp/src/resources/instances.rs @@ -84,7 +84,9 @@ async fn read_instances_resource() -> McpResult { .into_iter() .map(|info| { json!({ - "socket_path": info.socket_path.display().to_string(), + // Discovery is a Unix-socket scan; this output mirrors the + // `discover_instances` tool, which keeps the `socket_path` key. + "socket_path": info.endpoint.display().to_string(), "server_version": info.server_version, "protocol_version": info.protocol_version, "permissions": info.permissions, diff --git a/tools/aimdb-mcp/src/resources/records.rs b/tools/aimdb-mcp/src/resources/records.rs index 4965ecc..846ec58 100644 --- a/tools/aimdb-mcp/src/resources/records.rs +++ b/tools/aimdb-mcp/src/resources/records.rs @@ -63,7 +63,7 @@ pub async fn list_records_resources() -> McpResult> { // Generate a resource for each instance let resources: Vec = instances .into_iter() - .map(|info| records_resource_for_socket(&info.socket_path.display().to_string())) + .map(|info| records_resource_for_socket(&info.endpoint.display().to_string())) .collect(); Ok(resources) diff --git a/tools/aimdb-mcp/src/server.rs b/tools/aimdb-mcp/src/server.rs index 4324d4c..efb5935 100644 --- a/tools/aimdb-mcp/src/server.rs +++ b/tools/aimdb-mcp/src/server.rs @@ -121,12 +121,22 @@ impl McpServer { }, }; - // Build server info (version from Cargo.toml) + // Build server info (version from Cargo.toml). Advertise the prompts that + // `prompts/list` actually returns, derived from the same source rather than + // a hand-maintained list — empty in public mode, where prompts are disabled. + let prompts_available: Vec = if self.public_mode { + Vec::new() + } else { + prompts::list_prompts() + .into_iter() + .map(|p| p.name) + .collect() + }; let server_info = ServerInfo { name: "aimdb-mcp".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), metadata: Some(json!({ - "prompts_available": ["schema-help", "troubleshooting"], + "prompts_available": prompts_available, })), }; @@ -163,9 +173,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "required": [], @@ -178,9 +188,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_name": { "type": "string", @@ -197,9 +207,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_name": { "type": "string", @@ -219,9 +229,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "required": [], @@ -239,9 +249,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_name": { "type": "string", @@ -268,9 +278,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_name": { "type": "string", @@ -292,9 +302,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "required": [], @@ -307,9 +317,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "required": [], @@ -322,9 +332,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "required": [], @@ -721,9 +731,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "state_path": { "type": "string", @@ -740,9 +750,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_key": { "type": "string", @@ -759,9 +769,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." }, "record_key": { "type": "string", @@ -778,9 +788,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "additionalProperties": false @@ -792,9 +802,9 @@ impl McpServer { input_schema: json!({ "type": "object", "properties": { - "socket_path": { + "endpoint": { "type": "string", - "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted." + "description": "Endpoint URL (unix://PATH, serial://DEVICE?baud=N) or a bare path. Falls back to AIMDB_CONNECT env var if omitted." } }, "additionalProperties": false @@ -865,14 +875,14 @@ impl McpServer { ))); } - // In public mode, strip any client-supplied socket_path so - // resolve_socket_path falls back to the server-pinned --socket flag - // or the AIMDB_SOCKET env var (never a client-chosen path). + // In public mode, strip any client-supplied endpoint so + // resolve_endpoint falls back to the server-pinned --connect flag + // or the AIMDB_CONNECT env var (never a client-chosen path). // This prevents clients from probing arbitrary Unix sockets on the host. let arguments = if self.public_mode { params.arguments.map(|mut v| { if let Some(obj) = v.as_object_mut() { - obj.remove("socket_path"); + obj.remove("endpoint"); } v }) @@ -1101,18 +1111,18 @@ mod tests { assert!(matches!(err, McpError::MethodNotFound(_))); } - // Helper: assert that an explicit socket_path is stripped in public mode. + // Helper: assert that an explicit endpoint is stripped in public mode. // The stripping is confirmed by getting InvalidParams (no socket configured) // rather than a connection error to the attacker-supplied path. - async fn assert_socket_path_stripped(tool: &str) { + async fn assert_endpoint_stripped(tool: &str) { // Clear env so it doesn't interfere with the expected InvalidParams result. - std::env::remove_var("AIMDB_SOCKET"); + std::env::remove_var("AIMDB_CONNECT"); let server = McpServer::new().with_public_mode(true); server.set_state(ServerState::Ready).await; let params = ToolCallParams { name: tool.to_string(), - arguments: Some(json!({ "socket_path": "/tmp/evil.sock" })), + arguments: Some(json!({ "endpoint": "/tmp/evil.sock" })), }; let err = server.handle_tools_call(params).await.unwrap_err(); assert!( @@ -1122,26 +1132,26 @@ mod tests { } #[tokio::test] - async fn public_mode_strips_socket_path_list_records() { - assert_socket_path_stripped("list_records").await; + async fn public_mode_strips_endpoint_list_records() { + assert_endpoint_stripped("list_records").await; } #[tokio::test] - async fn public_mode_strips_socket_path_get_record() { - assert_socket_path_stripped("get_record").await; + async fn public_mode_strips_endpoint_get_record() { + assert_endpoint_stripped("get_record").await; } #[tokio::test] - async fn public_mode_strips_socket_path_discover_instances() { + async fn public_mode_strips_endpoint_discover_instances() { // discover_instances scans the filesystem directly — it doesn't call - // resolve_socket_path, so stripping socket_path doesn't cause InvalidParams. + // resolve_endpoint, so stripping endpoint doesn't cause InvalidParams. // The expected outcome is that the tool runs normally (no instances found in - // the test environment), confirming the evil socket_path was not connected to. + // the test environment), confirming the evil endpoint was not connected to. let server = McpServer::new().with_public_mode(true); server.set_state(ServerState::Ready).await; let params = ToolCallParams { name: "discover_instances".to_string(), - arguments: Some(json!({ "socket_path": "/tmp/evil.sock" })), + arguments: Some(json!({ "endpoint": "/tmp/evil.sock" })), }; let result = server.handle_tools_call(params).await; // The tool is allowed (not MethodNotFound) and does not attempt to connect diff --git a/tools/aimdb-mcp/src/tools/architecture.rs b/tools/aimdb-mcp/src/tools/architecture.rs index a4d215d..b303b46 100644 --- a/tools/aimdb-mcp/src/tools/architecture.rs +++ b/tools/aimdb-mcp/src/tools/architecture.rs @@ -773,8 +773,8 @@ pub async fn rename_record(args: Option) -> McpResult { #[derive(Debug, Deserialize)] struct ValidateInstanceParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, #[serde(default)] state_path: Option, } @@ -786,7 +786,7 @@ pub async fn validate_against_instance(args: Option) -> McpResult debug!("validate_against_instance called"); let params: ValidateInstanceParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("validate_against_instance: {e}")))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; let state_path = params .state_path @@ -803,7 +803,7 @@ pub async fn validate_against_instance(args: Option) -> McpResult })?; // Connect and list live records - let client = AimxConnection::connect(&socket_path) + let client = AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)?; diff --git a/tools/aimdb-mcp/src/tools/buffer_metrics.rs b/tools/aimdb-mcp/src/tools/buffer_metrics.rs index f14a05e..2e04cd5 100644 --- a/tools/aimdb-mcp/src/tools/buffer_metrics.rs +++ b/tools/aimdb-mcp/src/tools/buffer_metrics.rs @@ -13,24 +13,24 @@ use tracing::debug; #[derive(Debug, Deserialize)] struct GetBufferMetricsParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, /// Substring matched against record names (e.g. `"Temperature"`). record_key: String, } #[derive(Debug, Deserialize)] struct ResetBufferMetricsParams { - socket_path: Option, + endpoint: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(endpoint: &str) -> McpResult { if let Some(pool) = super::connection_pool() { - pool.get_connection(socket_path) + pool.get_connection(endpoint) .await .map_err(McpError::Client) } else { - AimxConnection::connect(socket_path) + AimxConnection::connect(endpoint) .await .map_err(McpError::Client) } @@ -43,9 +43,9 @@ pub async fn get_buffer_metrics(args: Option) -> McpResult { debug!("get_buffer_metrics called"); let params: GetBufferMetricsParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("get_buffer_metrics: {e}")))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - let client = connect(&socket_path).await?; + let client = connect(&endpoint).await?; let raw = client.list_records().await.map_err(McpError::Client)?; let matching: Vec<_> = raw @@ -73,9 +73,9 @@ pub async fn reset_buffer_metrics(args: Option) -> McpResult { debug!("reset_buffer_metrics called"); let params: ResetBufferMetricsParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("reset_buffer_metrics: {e}")))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - let client = connect(&socket_path).await?; + let client = connect(&endpoint).await?; match client.reset_buffer_metrics().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/graph.rs b/tools/aimdb-mcp/src/tools/graph.rs index bdc774e..24793e2 100644 --- a/tools/aimdb-mcp/src/tools/graph.rs +++ b/tools/aimdb-mcp/src/tools/graph.rs @@ -9,22 +9,22 @@ use tracing::debug; /// Parameters for graph_nodes tool #[derive(Debug, Clone, Serialize, Deserialize)] struct GraphNodesParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, } /// Parameters for graph_edges tool #[derive(Debug, Clone, Serialize, Deserialize)] struct GraphEdgesParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, } /// Parameters for graph_topo_order tool #[derive(Debug, Clone, Serialize, Deserialize)] struct GraphTopoOrderParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, } /// Get all nodes in the dependency graph @@ -34,7 +34,7 @@ struct GraphTopoOrderParams { /// and connection counts. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// /// # Returns /// - Array of GraphNode objects with: @@ -50,18 +50,18 @@ pub async fn graph_nodes(args: Option) -> McpResult { // Parse parameters let params: GraphNodesParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - debug!("🔌 Connecting to {}", socket_path); + debug!("🔌 Connecting to {}", endpoint); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; @@ -82,7 +82,7 @@ pub async fn graph_nodes(args: Option) -> McpResult { /// Edges show how data flows from sources through transforms to consumers. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// /// # Returns /// - Array of GraphEdge objects with: @@ -95,18 +95,18 @@ pub async fn graph_edges(args: Option) -> McpResult { // Parse parameters let params: GraphEdgesParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - debug!("🔌 Connecting to {}", socket_path); + debug!("🔌 Connecting to {}", endpoint); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; @@ -128,7 +128,7 @@ pub async fn graph_edges(args: Option) -> McpResult { /// for spawn ordering and reflects the proper initialization sequence. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// /// # Returns /// - Array of record keys in topological order @@ -138,18 +138,18 @@ pub async fn graph_topo_order(args: Option) -> McpResult { // Parse parameters let params: GraphTopoOrderParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - debug!("🔌 Connecting to {}", socket_path); + debug!("🔌 Connecting to {}", endpoint); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; diff --git a/tools/aimdb-mcp/src/tools/instance.rs b/tools/aimdb-mcp/src/tools/instance.rs index 4f16750..bf8283a 100644 --- a/tools/aimdb-mcp/src/tools/instance.rs +++ b/tools/aimdb-mcp/src/tools/instance.rs @@ -41,7 +41,7 @@ pub async fn discover_instances(_args: Option) -> McpResult { let discovered: Vec = instances .into_iter() .map(|info| DiscoveredInstance { - socket_path: info.socket_path.display().to_string(), + socket_path: info.endpoint.display().to_string(), server_version: info.server_version, protocol_version: info.protocol_version, permissions: info.permissions, @@ -59,15 +59,15 @@ pub async fn discover_instances(_args: Option) -> McpResult { /// Parameters for get_instance_info tool #[derive(Debug, Deserialize)] struct GetInstanceInfoParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint URL or socket path of the AimDB instance (falls back to AIMDB_CONNECT env) + endpoint: Option, } /// Result from get_instance_info tool #[derive(Debug, Serialize)] pub struct InstanceInfoResult { - /// Unix socket path - pub socket_path: String, + /// Endpoint the instance was reached at + pub endpoint: String, /// Server version pub server_version: String, /// Protocol version @@ -86,24 +86,27 @@ pub struct InstanceInfoResult { /// /// Connects to the instance and retrieves server metadata from the welcome message. pub async fn get_instance_info(args: Option) -> McpResult { - let params: GetInstanceInfoParams = match args { - Some(value) => serde_json::from_value(value)?, - None => { - return Err(crate::error::McpError::InvalidParams( - "Missing socket_path".into(), - )) + // Omitted args (or `{}`) is not an error here — it just means "no explicit + // endpoint", so resolution can still fall back to `--connect` / AIMDB_CONNECT. + let explicit = match args { + Some(value) => { + let params: GetInstanceInfoParams = serde_json::from_value(value).map_err(|e| { + crate::error::McpError::InvalidParams(format!("get_instance_info: {e}")) + })?; + params.endpoint } + None => None, }; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(explicit)?; - debug!("🔍 Getting instance info for: {}", socket_path); + debug!("🔍 Getting instance info for: {}", endpoint); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path).await? + pool.get_connection(&endpoint).await? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path).await? + AimxConnection::connect(&endpoint).await? }; // Get server info from the welcome message @@ -111,7 +114,7 @@ pub async fn get_instance_info(args: Option) -> McpResult { // Convert to result format let result = InstanceInfoResult { - socket_path: socket_path.clone(), + endpoint: endpoint.clone(), server_version: server_info.server.clone(), protocol_version: server_info.version.clone(), permissions: server_info.permissions.clone(), @@ -153,13 +156,13 @@ mod tests { let result = get_instance_info(None).await; assert!(result.is_err()); let err = result.unwrap_err(); - assert!(err.message().contains("Missing socket_path")); + assert!(err.message().contains("Missing endpoint")); } #[tokio::test] async fn test_get_instance_info_invalid_socket() { let params = serde_json::json!({ - "socket_path": "/tmp/nonexistent.sock" + "endpoint": "/tmp/nonexistent.sock" }); let result = get_instance_info(Some(params)).await; assert!(result.is_err()); diff --git a/tools/aimdb-mcp/src/tools/mod.rs b/tools/aimdb-mcp/src/tools/mod.rs index eb94120..291736f 100644 --- a/tools/aimdb-mcp/src/tools/mod.rs +++ b/tools/aimdb-mcp/src/tools/mod.rs @@ -16,17 +16,18 @@ pub mod schema; // Global connection pool (initialized once) static CONNECTION_POOL: OnceCell = OnceCell::new(); -// Default socket path set by --socket at startup (takes precedence over AIMDB_SOCKET env var) -static DEFAULT_SOCKET: OnceCell = OnceCell::new(); +// Default endpoint set by --connect at startup (takes precedence over the +// AIMDB_CONNECT env var) +static DEFAULT_ENDPOINT: OnceCell = OnceCell::new(); /// Initialize the connection pool for tools pub fn init_connection_pool(pool: ConnectionPool) { CONNECTION_POOL.set(pool).ok(); } -/// Set the default socket path (called once at startup from --socket flag). -pub fn set_default_socket(path: String) { - DEFAULT_SOCKET.set(path).ok(); +/// Set the default endpoint (called once at startup from the --connect flag). +pub fn set_default_endpoint(endpoint: String) { + DEFAULT_ENDPOINT.set(endpoint).ok(); } /// Get the connection pool @@ -34,18 +35,20 @@ pub(crate) fn connection_pool() -> Option<&'static ConnectionPool> { CONNECTION_POOL.get() } -/// Resolve the socket path from an explicit argument, the `--socket` flag, or -/// the `AIMDB_SOCKET` env var (checked in that order). +/// Resolve the endpoint from an explicit argument, the `--connect` flag, or the +/// `AIMDB_CONNECT` env var (checked in that order). The value is a `scheme://` +/// URL (`unix://PATH`, `serial://DEVICE?baud=N`) or a bare path. /// /// Returns an error if none are set. -pub(crate) fn resolve_socket_path(explicit: Option) -> crate::error::McpResult { +pub(crate) fn resolve_endpoint(explicit: Option) -> crate::error::McpResult { explicit - .or_else(|| DEFAULT_SOCKET.get().cloned()) - .or_else(|| std::env::var("AIMDB_SOCKET").ok()) + .or_else(|| DEFAULT_ENDPOINT.get().cloned()) + .or_else(|| std::env::var("AIMDB_CONNECT").ok()) .filter(|s| !s.is_empty()) .ok_or_else(|| { crate::error::McpError::InvalidParams( - "Missing socket_path (pass it explicitly, use --socket, or set AIMDB_SOCKET env var)".into(), + "Missing endpoint (pass it explicitly, use --connect, or set AIMDB_CONNECT env var)" + .into(), ) }) } diff --git a/tools/aimdb-mcp/src/tools/profiling.rs b/tools/aimdb-mcp/src/tools/profiling.rs index 1a97d47..cc69d84 100644 --- a/tools/aimdb-mcp/src/tools/profiling.rs +++ b/tools/aimdb-mcp/src/tools/profiling.rs @@ -15,23 +15,23 @@ use tracing::debug; #[derive(Debug, Deserialize)] struct GetStageProfilingParams { - socket_path: Option, + endpoint: Option, /// Substring matched against record name/key (e.g. `"Temperature"`). record_key: String, } #[derive(Debug, Deserialize)] struct ResetStageProfilingParams { - socket_path: Option, + endpoint: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(endpoint: &str) -> McpResult { if let Some(pool) = super::connection_pool() { - pool.get_connection(socket_path) + pool.get_connection(endpoint) .await .map_err(McpError::Client) } else { - AimxConnection::connect(socket_path) + AimxConnection::connect(endpoint) .await .map_err(McpError::Client) } @@ -47,9 +47,9 @@ pub async fn get_stage_profiling(args: Option) -> McpResult { debug!("get_stage_profiling called"); let params: GetStageProfilingParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("get_stage_profiling: {e}")))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - let client = connect(&socket_path).await?; + let client = connect(&endpoint).await?; let records = client.list_records().await.map_err(McpError::Client)?; let mut out = Vec::new(); @@ -108,9 +108,9 @@ pub async fn reset_stage_profiling(args: Option) -> McpResult { debug!("reset_stage_profiling called"); let params: ResetStageProfilingParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("reset_stage_profiling: {e}")))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; - let client = connect(&socket_path).await?; + let client = connect(&endpoint).await?; match client.reset_stage_profiling().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/record.rs b/tools/aimdb-mcp/src/tools/record.rs index c6d7edb..18d16f6 100644 --- a/tools/aimdb-mcp/src/tools/record.rs +++ b/tools/aimdb-mcp/src/tools/record.rs @@ -10,15 +10,15 @@ use tracing::debug; /// Parameters for list_records tool #[derive(Debug, Clone, Serialize, Deserialize)] struct ListRecordsParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, } /// Parameters for get_record tool #[derive(Debug, Clone, Serialize, Deserialize)] struct GetRecordParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, /// Name of the record to retrieve record_name: String, } @@ -26,8 +26,8 @@ struct GetRecordParams { /// Parameters for set_record tool #[derive(Debug, Clone, Serialize, Deserialize)] struct SetRecordParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, /// Name of the record to update record_name: String, /// New value for the record (JSON) @@ -37,8 +37,8 @@ struct SetRecordParams { /// Parameters for drain_record tool #[derive(Debug, Clone, Serialize, Deserialize)] struct DrainRecordParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + endpoint: Option, /// Name of the record to drain record_name: String, /// Maximum number of values to drain (optional) @@ -72,11 +72,11 @@ struct RecordInfo { /// List all records from a specific AimDB instance /// -/// Connects to the specified socket and retrieves the list of all +/// Connects to the instance and retrieves the list of all /// registered records with their metadata. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// /// # Returns /// - Array of records with metadata @@ -87,17 +87,17 @@ pub async fn list_records(args: Option) -> McpResult { let params: ListRecordsParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; - debug!("🔌 Connecting to {}", socket_path); + let endpoint = super::resolve_endpoint(params.endpoint)?; + debug!("🔌 Connecting to {}", endpoint); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; @@ -130,11 +130,11 @@ pub async fn list_records(args: Option) -> McpResult { /// Get the current value of a specific record /// -/// Connects to the specified socket and retrieves the current value +/// Connects to the instance and retrieves the current value /// of the named record. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// - `record_name` (required): Name of the record to retrieve /// /// # Returns @@ -146,10 +146,10 @@ pub async fn get_record(args: Option) -> McpResult { let params: GetRecordParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; debug!( "🔌 Connecting to {} to get record '{}'", - socket_path, params.record_name + endpoint, params.record_name ); // Reuse the *persistent* connection (the same pool `drain_record` uses) rather @@ -164,7 +164,7 @@ pub async fn get_record(args: Option) -> McpResult { .ok_or_else(|| McpError::Internal("Connection pool not initialized".to_string()))?; let client_arc = pool - .get_drain_client(&socket_path) + .get_drain_client(&endpoint) .await .map_err(McpError::Client)?; @@ -198,7 +198,7 @@ pub async fn get_record(args: Option) -> McpResult { // A genuine connection/protocol error (not a warming ring cursor) means the // persistent client is unhealthy — drop it so the next call reconnects. if !cursor_warming { - let socket = socket_path.clone(); + let socket = endpoint.clone(); let pool = pool.clone(); tokio::spawn(async move { pool.invalidate_drain_client(&socket).await }); } @@ -209,10 +209,10 @@ pub async fn get_record(args: Option) -> McpResult { /// Set the value of a writable record /// -/// Connects to the specified socket and updates the value of a writable record. +/// Connects to the instance and updates the value of a writable record. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// - `record_name` (required): Name of the record to update /// - `value` (required): New value for the record (JSON) /// @@ -225,20 +225,20 @@ pub async fn set_record(args: Option) -> McpResult { let params: SetRecordParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; debug!( "🔌 Connecting to {} to set record '{}'", - socket_path, params.record_name + endpoint, params.record_name ); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; @@ -261,7 +261,7 @@ pub async fn set_record(args: Option) -> McpResult { /// of accumulated data. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// - `record_name` (required): Name of the record to drain /// - `limit` (optional): Maximum number of values to drain /// @@ -274,10 +274,10 @@ pub async fn drain_record(args: Option) -> McpResult { let params: DrainRecordParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; debug!( "🔌 Connecting to {} to drain record '{}'", - socket_path, params.record_name + endpoint, params.record_name ); // Use persistent drain client so the server-side drain reader persists @@ -286,7 +286,7 @@ pub async fn drain_record(args: Option) -> McpResult { .ok_or_else(|| McpError::Internal("Connection pool not initialized".to_string()))?; let client_arc = pool - .get_drain_client(&socket_path) + .get_drain_client(&endpoint) .await .map_err(McpError::Client)?; @@ -299,7 +299,7 @@ pub async fn drain_record(args: Option) -> McpResult { .await .map_err(|e| { // On connection error, invalidate so next call reconnects - let socket = socket_path.clone(); + let socket = endpoint.clone(); let pool = pool.clone(); tokio::spawn(async move { pool.invalidate_drain_client(&socket).await }); McpError::Client(e) @@ -308,7 +308,7 @@ pub async fn drain_record(args: Option) -> McpResult { .drain_record(¶ms.record_name) .await .map_err(|e| { - let socket = socket_path.clone(); + let socket = endpoint.clone(); let pool = pool.clone(); tokio::spawn(async move { pool.invalidate_drain_client(&socket).await }); McpError::Client(e) @@ -330,8 +330,8 @@ mod tests { use serde_json::json; #[tokio::test] - async fn test_list_records_missing_socket_path() { - // Should fail without socket_path parameter + async fn test_list_records_missing_endpoint() { + // Should fail without endpoint parameter let result = list_records(None).await; assert!(result.is_err()); @@ -343,7 +343,7 @@ mod tests { async fn test_list_records_invalid_socket() { // Should fail with non-existent socket let params = json!({ - "socket_path": "/tmp/nonexistent.sock" + "endpoint": "/tmp/nonexistent.sock" }); let result = list_records(Some(params)).await; @@ -369,7 +369,7 @@ mod tests { async fn test_get_record_invalid_socket() { // Should fail with non-existent socket let params = json!({ - "socket_path": "/tmp/nonexistent.sock", + "endpoint": "/tmp/nonexistent.sock", "record_name": "TestRecord" }); @@ -391,7 +391,7 @@ mod tests { async fn test_set_record_invalid_socket() { // Should fail with non-existent socket let params = json!({ - "socket_path": "/tmp/nonexistent.sock", + "endpoint": "/tmp/nonexistent.sock", "record_name": "TestRecord", "value": {"test": "value"} }); diff --git a/tools/aimdb-mcp/src/tools/schema.rs b/tools/aimdb-mcp/src/tools/schema.rs index 0955b16..de2b8af 100644 --- a/tools/aimdb-mcp/src/tools/schema.rs +++ b/tools/aimdb-mcp/src/tools/schema.rs @@ -9,8 +9,8 @@ use tracing::debug; /// Parameters for query_schema tool #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuerySchemaParams { - /// Unix socket path to the AimDB instance (falls back to AIMDB_SOCKET env) - pub socket_path: Option, + /// Endpoint of the AimDB instance: a `scheme://` URL or bare path (falls back to AIMDB_CONNECT env). + pub endpoint: Option, /// Name of the record to query schema for pub record_name: String, @@ -92,7 +92,7 @@ fn infer_json_schema(value: &Value) -> McpResult { /// by analyzing its current value. Returns the schema along with record metadata. /// /// # Parameters -/// - `socket_path` (required): Unix socket path to the AimDB instance +/// - `endpoint` (optional): endpoint URL (`unix://PATH`, `serial://DEVICE?baud=N`) or bare path; falls back to `--connect` / `AIMDB_CONNECT` /// - `record_name` (required): Name of the record to query /// - `include_example` (optional): Include current value as example (default: true) /// @@ -109,21 +109,21 @@ pub async fn query_schema(args: Option) -> McpResult { // Parse parameters let params: QuerySchemaParams = serde_json::from_value(args.unwrap_or(Value::Null)) .map_err(|e| McpError::InvalidParams(format!("Invalid parameters: {}", e)))?; - let socket_path = super::resolve_socket_path(params.socket_path)?; + let endpoint = super::resolve_endpoint(params.endpoint)?; debug!( "📊 Querying schema for record '{}' at {}", - params.record_name, socket_path + params.record_name, endpoint ); // Get or create connection from pool (if available) let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) + pool.get_connection(&endpoint) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) + AimxConnection::connect(&endpoint) .await .map_err(McpError::Client)? }; @@ -332,12 +332,12 @@ mod tests { #[test] fn test_query_schema_params_defaults() { let params: QuerySchemaParams = serde_json::from_value(json!({ - "socket_path": "/tmp/test.sock", + "endpoint": "/tmp/test.sock", "record_name": "test::Record" })) .unwrap(); - assert_eq!(params.socket_path, Some("/tmp/test.sock".to_string())); + assert_eq!(params.endpoint, Some("/tmp/test.sock".to_string())); assert_eq!(params.record_name, "test::Record"); assert!(params.include_example); // Should default to true } @@ -345,7 +345,7 @@ mod tests { #[test] fn test_query_schema_params_explicit_example() { let params: QuerySchemaParams = serde_json::from_value(json!({ - "socket_path": "/tmp/test.sock", + "endpoint": "/tmp/test.sock", "record_name": "test::Record", "include_example": false }))