diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml new file mode 100644 index 0000000..3b5b400 --- /dev/null +++ b/.github/workflows/host-tests.yml @@ -0,0 +1,57 @@ +name: Host Tests + +on: + push: + branches: + - main + - feature/command-socket-integration + pull_request: + +jobs: + host-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout Sockets + uses: actions/checkout@v4 + + - name: Checkout ESPressio Command 0.2.0 + uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Command + ref: 0.2.0 + path: deps/ESPressio-Command + + - name: Checkout ESPressio Timing 2.2.2 + uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Timing + ref: 2.2.2 + path: deps/ESPressio-Timing + + - name: Checkout ESPressio Units 0.2.1 + uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Units + ref: 0.2.1 + path: deps/ESPressio-Units + + - name: Checkout ESPressio Observable 3.0.1 + uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + + - name: Configure + run: >- + cmake -S tests -B build + -DESPRESSIO_COMMAND_INCLUDE_DIR="$GITHUB_WORKSPACE/deps/ESPressio-Command/src" + -DESPRESSIO_TIMING_INCLUDE_DIR="$GITHUB_WORKSPACE/deps/ESPressio-Timing/src" + -DESPRESSIO_UNITS_INCLUDE_DIR="$GITHUB_WORKSPACE/deps/ESPressio-Units/src" + -DESPRESSIO_OBSERVABLE_INCLUDE_DIR="$GITHUB_WORKSPACE/deps/ESPressio-Observable/src" + + - name: Build + run: cmake --build build --parallel + + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c798d..d18be89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.3.0 — 2026-08-20 + +### Added +- Added opt-in ESPressio Command 0.2.x integration for remote Command invocation over sockets. +- Added host-testable `SocketCommandSession` with line-oriented and structured-binary request modes. +- Added `TCPCommandServer` with isolated per-client Command sessions. +- Added request/connection metadata, policy hooks, result observers, correlation IDs, bounded request handling, and structured request/response framing. +- Added a TCP Command server example and comprehensive host tests for Command framing, dispatch, validation, session isolation, policy and error paths. +- Added a permanent GitHub Actions host-test workflow pinned to released ESPressio dependencies. + +### Changed +- Updated package/component version metadata to 0.3.0. +- Updated README and ESPressio dependency documentation for optional Command integration. +- Expanded host regression testing to retain coverage of the existing socket clock-synchronization protocol. + +### Compatibility +- Core ESPressio Sockets remains independent of ESPressio Command. +- Existing Event Transport and Timing synchronization APIs remain source-compatible. +- ESPressio Command is required only when Command integration headers are selected. + ## 0.2.3 — 2026-08-20 ### Changed diff --git a/COMMAND_INTEGRATION.md b/COMMAND_INTEGRATION.md new file mode 100644 index 0000000..ad2602e --- /dev/null +++ b/COMMAND_INTEGRATION.md @@ -0,0 +1,262 @@ +# ESPressio Sockets Command Integration + +ESPressio Sockets 0.3.0 adds opt-in integration with **ESPressio Command >= 0.2.0 < 1.0.0**. + +Core ESPressio Sockets remains independent of ESPressio Command. Command support is activated only when the corresponding integration headers are selected. + +## Dependency direction + +```text +ESPressio Sockets core + -> no Command dependency + +Socket Command integration + - - -> ESPressio Command >= 0.2.0 < 1.0.0 +``` + +`SocketCommandSession` owns byte-stream framing, bounded request accumulation, socket-side metadata, policy hooks and result transport. ESPressio Command continues to own Command definition, parsing, typed parameter validation, routing and callback execution. + +## Architecture + +```text +TCP client + | + v +TCPCommandServer + | + +-- per-client SocketCommandSession + +-- framing / bounded buffering + +-- connection metadata + +-- policy / result hooks + | + v +ESPressio CommandRegistry + | + v +typed Command callback + | + v +CommandResult + | + v +originating TCP client +``` + +## Public headers + +```cpp +#include +#include +#include +#include +``` + +These headers are deliberately not included by `ESPressio_Sockets.hpp`, preserving the optional dependency. + +## TCP Command server + +`TCPCommandServer` accepts multiple TCP clients and assigns an independent `SocketCommandSession` to each connection. + +```cpp +#include +#include + +using namespace ESPressio; + +Sockets::TCPCommandServer server; + +auto& commands = Command::CommandRegistry::GetInstance(); +commands.Command("system") + .Command("status") + .OnExecute([](const Command::CommandContext&) { + return Command::CommandResult::Ok("System OK"); + }); + +Sockets::TCPCommandServerConfig config; +config.Port = 2323; +config.MaximumClients = 4; +config.Session.Mode = Sockets::SocketCommandMode::Line; +config.Session.MaximumRequestBytes = 512; + +server.Initialize(config, commands); +``` + +The consuming application remains responsible for establishing Wi-Fi/network connectivity before the server is initialized. + +## Line-oriented mode + +Line mode is intended for interactive or simple text clients. + +Requests use normal ESPressio Command syntax: + +```text +system status +gpio write 2 high +``` + +Each completed request produces one newline-delimited response: + +```text +OK 0 System OK +ERR 1 Unknown command 'example' +``` + +LF and CRLF input are accepted. Quoting, escaping, Command-tree resolution and typed parameter parsing are delegated to ESPressio Command. + +Fragmented TCP reads are accumulated until a complete line is available. Multiple complete commands received in one TCP read are processed independently. + +## Structured-binary mode + +Machine callers can avoid manufacturing command-line text by sending a structured `CommandInvocation` representation. + +The version-1 request contains: + +```text +magic +protocol version +request/correlation ID +Command path +positional parameters +named parameters +raw caller string +``` + +The version-1 response contains: + +```text +magic +protocol version +matching request/correlation ID +success/failure +CommandResult code +CommandResult message +``` + +Structured payloads use a 32-bit length prefix and an ESPressio-owned binary encoding. + +This mode intentionally does **not** require ESPressio Serializable or JSON. + +The protocol helpers are exposed through `ESPressio_SocketCommandProtocol.hpp`: + +```cpp +SocketCommandProtocol::EncodeRequest(...) +SocketCommandProtocol::DecodeRequest(...) +SocketCommandProtocol::EncodeResponse(...) +SocketCommandProtocol::DecodeResponse(...) +SocketCommandProtocol::FrameStructuredPayload(...) +``` + +## Session isolation + +Each TCP client receives an independent `SocketCommandSession`. + +The following state is therefore isolated per connection: + +- partial line input; +- structured-frame accumulation; +- request/correlation state; +- remote address/port metadata; +- session ID; +- protocol recovery state. + +A partial request from one client can never be completed by bytes arriving from another client. + +## Resource limits + +`SocketCommandSessionConfig` exposes: + +```text +Mode +MaximumRequestBytes +DisconnectOnProtocolError +IgnoreEmptyLines +``` + +Line requests are bounded by `MaximumRequestBytes`. Oversized lines are discarded through the next newline before normal processing resumes. + +Structured requests declare their payload length before execution and are rejected if the declared payload exceeds the configured limit. + +`TCPCommandServerConfig::MaximumClients` is additionally bounded by `ESPRESSIO_SOCKETS_MAX_TCP_CLIENTS`. + +## Connection metadata + +Each invocation is associated with `SocketCommandMetadata`: + +```text +Transport +RemoteAddress +RemotePort +SessionID +RequestID +``` + +`TCPCommandServer` populates the TCP connection fields automatically. + +This metadata is supplied to the socket-side policy and result-observer hooks so applications can implement authorization, rate limiting, audit, diagnostics or transport-specific policy without coupling those concerns to Command callbacks. + +## Policy hook + +A server can apply a policy before a remote Command is executed: + +```cpp +server.SetPolicy( + [](const Sockets::SocketCommandInvocationContext& context) { + if (context.Metadata.SessionID == 0) { + return Command::CommandResult::Error( + "Invalid remote session" + ); + } + + return Command::CommandResult::Ok(); + } +); +``` + +Returning an error prevents the Command callback from executing and returns that `CommandResult` to the originating client. + +## Result observation + +Completed remote invocations can be observed independently of application Command callbacks: + +```cpp +server.SetResultObserver( + [](const Sockets::SocketCommandInvocationContext& context, + const Command::CommandResult& result) { + // Diagnostics or audit handling. + } +); +``` + +## Command and Event semantics + +Socket Command and Event integrations are complementary: + +```text +Command + remote caller requests an action + +Event + a device reports that something happened +``` + +A typical application may therefore receive a Command over TCP, perform the requested operation, then dispatch an Event describing the resulting state change. + +## PlatformIO + +```ini +lib_deps = + flowduino/ESPressio-Sockets@^0.3.0 + flowduino/ESPressio-Command@^0.2.0 +``` + +The existing Event and Timing dependencies remain required only when their corresponding Sockets integrations are selected. + +## Example + +See: + +```text +examples/TCPCommandServer/TCPCommandServer.ino +``` + +for an ESP32 example registering application Commands and exposing them through the TCP Command server. diff --git a/ESPRESSIO_DEPENDENCY_CHART.md b/ESPRESSIO_DEPENDENCY_CHART.md index 923cded..c64bb1f 100644 --- a/ESPRESSIO_DEPENDENCY_CHART.md +++ b/ESPRESSIO_DEPENDENCY_CHART.md @@ -361,3 +361,8 @@ Units + Serializable ``` This keeps the individual libraries independently useful while allowing progressively richer ESPressio compositions without imposing unnecessary dependencies on applications that do not use those integrations. + + +## ESPressio Sockets → ESPressio Command — opt-in + +Sockets 0.3.0 optionally consumes **ESPressio Command >= 0.2.0 < 1.0.0** when `SocketCommandSession` or `TCPCommandServer` is selected. Core Sockets remains independent of Command. The integration owns socket framing, bounded per-client state, connection/request metadata and result transport; ESPressio Command continues to own Command definition, typed parsing/validation, routing and callback execution. The structured socket protocol does not require ESPressio Serializable. diff --git a/README.md b/README.md index 77a601c..3d0aa75 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ ESPressio Sockets provides IP/socket-oriented communication adapters separately ## Latest Stable Version -The current repository version is **0.2.3**. +The current repository version is **0.3.0**. ## Compatibility -ESPressio Sockets `0.2.3` targets the **ESP32 family under Arduino-ESP32** and uses C++17. +ESPressio Sockets `0.3.0` targets the **ESP32 family under Arduino-ESP32** and uses C++17. The library uses Arduino-ESP32 native networking classes for UDP, TCP and TLS. WebSocket support is provided through the mature Links2004 `arduinoWebSockets` library. @@ -61,6 +61,8 @@ ESPressio Event >= 5.7.1 < 6.0.0 and therefore the Serializable support used by ESPressio Event Transport. +Command invocation is also opt-in and targets **ESPressio Command >= 0.2.0 < 1.0.0**. See [Command Integration](COMMAND_INTEGRATION.md) for the TCP server, line/structured protocols, session metadata, policy hooks, limits, and examples. + WebSocket adapters additionally use: ```text @@ -570,7 +572,7 @@ build_flags = -std=gnu++17 lib_deps = - flowduino/ESPressio-Sockets@^0.2.3 + flowduino/ESPressio-Sockets@^0.3.0 flowduino/ESPressio-Event@^5.7.1 links2004/WebSockets@^2.3.6 knolleary/PubSubClient@^2.8 diff --git a/component.mk b/component.mk index 6dfb23f..56d6d35 100644 --- a/component.mk +++ b/component.mk @@ -4,6 +4,6 @@ CXXFLAGS += -std=gnu++17 CPPFLAGS += \ -DESPRESSIO_SOCKETS \ -DESPRESSIO_SOCKETS_VERSION_MAJOR=0 \ - -DESPRESSIO_SOCKETS_VERSION_MINOR=2 \ - -DESPRESSIO_SOCKETS_VERSION_PATCH=3 \ - -DESPRESSIO_SOCKETS_VERSION_STRING=\"0.2.3\" + -DESPRESSIO_SOCKETS_VERSION_MINOR=3 \ + -DESPRESSIO_SOCKETS_VERSION_PATCH=0 \ + -DESPRESSIO_SOCKETS_VERSION_STRING=\"0.3.0\" diff --git a/examples/TCPCommandServer/TCPCommandServer.ino b/examples/TCPCommandServer/TCPCommandServer.ino new file mode 100644 index 0000000..26a8cb8 --- /dev/null +++ b/examples/TCPCommandServer/TCPCommandServer.ino @@ -0,0 +1,103 @@ +#include +#include +#include + +using namespace ESPressio; + +constexpr char WiFiSSID[] = "YOUR_SSID"; +constexpr char WiFiPassword[] = "YOUR_PASSWORD"; + +Sockets::TCPCommandServer CommandServer; + +void setup() { + Serial.begin(115200); + + WiFi.begin(WiFiSSID, WiFiPassword); + while (WiFi.status() != WL_CONNECTED) { + delay(100); + } + + auto& commands = + Command::CommandRegistry::GetInstance(); + + commands.Command("system") + .Command("status") + .OnExecute( + [](const Command::CommandContext&) { + return Command::CommandResult::Ok( + "System OK" + ); + } + ); + + auto& write = + commands.Command("gpio") + .Command("write"); + + write.Parameter("pin") + .Range(0, 48); + + write.Parameter("state"); + + write.OnExecute( + [](const Command::CommandContext& context) { + const int pin = + context.Get("pin"); + + const bool state = + context.Get("state"); + + pinMode(pin, OUTPUT); + digitalWrite( + pin, + state ? HIGH : LOW + ); + + return Command::CommandResult::Ok( + "GPIO updated" + ); + } + ); + + Sockets::TCPCommandServerConfig config; + config.Port = 2323; + config.MaximumClients = 4; + config.Session.Mode = + Sockets::SocketCommandMode::Line; + config.Session.MaximumRequestBytes = 512; + config.Session.DisconnectOnProtocolError = false; + + CommandServer.SetPolicy( + [](const Sockets::SocketCommandInvocationContext& context) { + Serial.printf( + "Command session=%llu remote=%s:%u request=%llu raw=%s\n", + static_cast( + context.Metadata.SessionID + ), + context.Metadata.RemoteAddress.c_str(), + context.Metadata.RemotePort, + static_cast( + context.Metadata.RequestID + ), + context.Invocation.raw.c_str() + ); + + return Command::CommandResult::Ok(); + } + ); + + if (!CommandServer.Initialize(config, commands)) { + Serial.println( + "TCP Command server failed to initialize" + ); + } else { + Serial.printf( + "TCP Command server listening on port %u\n", + config.Port + ); + } +} + +void loop() { + delay(1000); +} diff --git a/library.json b/library.json index 10f4788..82bf19b 100644 --- a/library.json +++ b/library.json @@ -1,7 +1,7 @@ { "name": "ESPressio-Sockets", - "description": "Socket-based ESPressio transports and Timing synchronization providers for ESP32, including UDP, TCP, TLS, WebSocket and MQTT Event Transport adapters.", - "keywords": "esp32,sockets,udp,tcp,tls,websocket,mqtt,event,transport,network,timing,clock,synchronization,sntp,espressio", + "description": "Socket-based ESPressio transports, Command invocation adapters, and Timing synchronization providers for ESP32.", + "keywords": "esp32,sockets,udp,tcp,tls,websocket,mqtt,event,command,cli,transport,network,timing,clock,synchronization,sntp,espressio", "authors": { "name": "Flowduino", "maintainer": true, @@ -16,7 +16,7 @@ "type": "git", "url": "https://github.com/Flowduino/ESPressio-Sockets.git" }, - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "frameworks": "arduino", "platforms": "espressif32", diff --git a/library.properties b/library.properties index 35b9923..6e7a24f 100644 --- a/library.properties +++ b/library.properties @@ -1,9 +1,9 @@ name=ESPressio-Sockets -version=0.2.3 +version=0.3.0 author=Flowduino maintainer=Flowduino -sentence=Socket-based Event Transport and System Clock synchronization implementations for the ESPressio ecosystem. -paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports plus opt-in ESPressio Timing System Clock synchronization over UDP, TCP, WebSocket and SNTP. +sentence=Socket-based Event Transport, Command invocation, and System Clock synchronization for the ESPressio ecosystem. +paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports, opt-in TCP ESPressio Command invocation, and opt-in ESPressio Timing System Clock synchronization. category=Communication url=https://github.com/Flowduino/ESPressio-Sockets architectures=esp32 diff --git a/src/ESPressio_SocketCommandProtocol.hpp b/src/ESPressio_SocketCommandProtocol.hpp new file mode 100644 index 0000000..997b12e --- /dev/null +++ b/src/ESPressio_SocketCommandProtocol.hpp @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ESPressio_SocketCommandTypes.hpp" + +namespace ESPressio::Sockets { + +namespace SocketCommandProtocol { + +constexpr uint32_t RequestMagic = 0x45534351UL; // ESCQ +constexpr uint32_t ResponseMagic = 0x45534352UL; // ESCR +constexpr uint8_t Version = 1; + +namespace Detail { + +inline void AppendU8(std::vector& out, uint8_t value) { + out.push_back(value); +} + +inline void AppendU16(std::vector& out, uint16_t value) { + out.push_back(static_cast((value >> 8) & 0xFF)); + out.push_back(static_cast(value & 0xFF)); +} + +inline void AppendU32(std::vector& out, uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + out.push_back(static_cast((value >> shift) & 0xFF)); + } +} + +inline void AppendU64(std::vector& out, uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) { + out.push_back(static_cast((value >> shift) & 0xFF)); + } +} + +inline void AppendI32(std::vector& out, int32_t value) { + AppendU32(out, static_cast(value)); +} + +inline bool AppendString16(std::vector& out, const std::string& value) { + if (value.size() > 0xFFFFU) return false; + AppendU16(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); + return true; +} + +inline bool AppendString32(std::vector& out, const std::string& value) { + if (value.size() > 0xFFFFFFFFULL) return false; + AppendU32(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); + return true; +} + +class Reader { +public: + Reader(const uint8_t* data, std::size_t size) : data_(data), size_(size) {} + + bool U8(uint8_t& value) { + if (!Need(1)) return false; + value = data_[offset_++]; + return true; + } + + bool U16(uint16_t& value) { + if (!Need(2)) return false; + value = static_cast((static_cast(data_[offset_]) << 8) | data_[offset_ + 1]); + offset_ += 2; + return true; + } + + bool U32(uint32_t& value) { + if (!Need(4)) return false; + value = 0; + for (int i = 0; i < 4; ++i) value = (value << 8) | data_[offset_ + i]; + offset_ += 4; + return true; + } + + bool U64(uint64_t& value) { + if (!Need(8)) return false; + value = 0; + for (int i = 0; i < 8; ++i) value = (value << 8) | data_[offset_ + i]; + offset_ += 8; + return true; + } + + bool I32(int32_t& value) { + uint32_t raw = 0; + if (!U32(raw)) return false; + value = static_cast(raw); + return true; + } + + bool String16(std::string& value) { + uint16_t length = 0; + if (!U16(length) || !Need(length)) return false; + value.assign(reinterpret_cast(data_ + offset_), length); + offset_ += length; + return true; + } + + bool String32(std::string& value) { + uint32_t length = 0; + if (!U32(length) || static_cast(length) > Remaining()) return false; + value.assign(reinterpret_cast(data_ + offset_), length); + offset_ += length; + return true; + } + + std::size_t Remaining() const { return size_ - offset_; } + bool Finished() const { return offset_ == size_; } + +private: + bool Need(std::size_t count) const { return count <= size_ - offset_; } + const uint8_t* data_ = nullptr; + std::size_t size_ = 0; + std::size_t offset_ = 0; +}; + +} + +inline bool EncodeRequest( + const SocketCommandInvocationContext& context, + std::vector& out +) { + out.clear(); + Detail::AppendU32(out, RequestMagic); + Detail::AppendU8(out, Version); + Detail::AppendU64(out, context.Metadata.RequestID); + + if (context.Invocation.path.size() > 0xFFFFU || + context.Invocation.positional.size() > 0xFFFFU || + context.Invocation.named.size() > 0xFFFFU) return false; + + Detail::AppendU16(out, static_cast(context.Invocation.path.size())); + for (const auto& item : context.Invocation.path) if (!Detail::AppendString16(out, item)) return false; + + Detail::AppendU16(out, static_cast(context.Invocation.positional.size())); + for (const auto& item : context.Invocation.positional) if (!Detail::AppendString16(out, item)) return false; + + Detail::AppendU16(out, static_cast(context.Invocation.named.size())); + for (const auto& item : context.Invocation.named) { + if (!Detail::AppendString16(out, item.first) || !Detail::AppendString16(out, item.second)) return false; + } + + if (!Detail::AppendString32(out, context.Invocation.raw)) return false; + return true; +} + +inline bool DecodeRequest( + const uint8_t* data, + std::size_t size, + SocketCommandInvocationContext& context +) { + if (data == nullptr) return false; + Detail::Reader reader(data, size); + uint32_t magic = 0; + uint8_t version = 0; + if (!reader.U32(magic) || magic != RequestMagic || !reader.U8(version) || version != Version) return false; + + context.Invocation = {}; + if (!reader.U64(context.Metadata.RequestID)) return false; + + uint16_t count = 0; + if (!reader.U16(count)) return false; + for (uint16_t i = 0; i < count; ++i) { + std::string value; + if (!reader.String16(value)) return false; + context.Invocation.path.push_back(std::move(value)); + } + + if (!reader.U16(count)) return false; + for (uint16_t i = 0; i < count; ++i) { + std::string value; + if (!reader.String16(value)) return false; + context.Invocation.positional.push_back(std::move(value)); + } + + if (!reader.U16(count)) return false; + for (uint16_t i = 0; i < count; ++i) { + std::string key, value; + if (!reader.String16(key) || !reader.String16(value)) return false; + context.Invocation.named[std::move(key)] = std::move(value); + } + + if (!reader.String32(context.Invocation.raw)) return false; + return reader.Finished() && !context.Invocation.path.empty(); +} + +inline bool EncodeResponse( + const SocketCommandResponse& response, + std::vector& out +) { + out.clear(); + Detail::AppendU32(out, ResponseMagic); + Detail::AppendU8(out, Version); + Detail::AppendU64(out, response.RequestID); + Detail::AppendU8(out, response.Result.success ? 1 : 0); + Detail::AppendI32(out, static_cast(response.Result.code)); + return Detail::AppendString32(out, response.Result.message); +} + +inline bool DecodeResponse( + const uint8_t* data, + std::size_t size, + SocketCommandResponse& response +) { + if (data == nullptr) return false; + Detail::Reader reader(data, size); + uint32_t magic = 0; + uint8_t version = 0, success = 0; + int32_t code = 0; + if (!reader.U32(magic) || magic != ResponseMagic || + !reader.U8(version) || version != Version || + !reader.U64(response.RequestID) || + !reader.U8(success) || success > 1 || + !reader.I32(code) || + !reader.String32(response.Result.message) || !reader.Finished()) return false; + response.Result.success = success != 0; + response.Result.code = static_cast(code); + return true; +} + +inline std::vector FrameStructuredPayload(const std::vector& payload) { + if (payload.size() > 0xFFFFFFFFULL) return {}; + std::vector out; + out.reserve(payload.size() + 4); + Detail::AppendU32(out, static_cast(payload.size())); + out.insert(out.end(), payload.begin(), payload.end()); + return out; +} + +} + +} diff --git a/src/ESPressio_SocketCommandSession.hpp b/src/ESPressio_SocketCommandSession.hpp new file mode 100644 index 0000000..735e5bf --- /dev/null +++ b/src/ESPressio_SocketCommandSession.hpp @@ -0,0 +1,242 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ESPressio_SocketCommandProtocol.hpp" +#include "ESPressio_SocketCommandTypes.hpp" + +namespace ESPressio::Sockets { + +class SocketCommandSession final { +public: + SocketCommandSession() = default; + + bool Initialize( + Command::CommandRegistry& registry, + const SocketCommandSessionConfig& config, + SocketCommandMetadata metadata, + SocketCommandWriteHandler writer + ) { + Shutdown(); + if (!writer || config.MaximumRequestBytes == 0) return false; + _registry = ®istry; + _config = config; + _metadata = std::move(metadata); + _writer = std::move(writer); + _initialized = true; + return true; + } + + void Shutdown() { + _registry = nullptr; + _writer = {}; + _policy = {}; + _observer = {}; + _line.clear(); + _structured.clear(); + _discardUntilNewline = false; + _expectedStructuredBytes = 0; + _initialized = false; + } + + void ResetInput() { + _line.clear(); + _structured.clear(); + _discardUntilNewline = false; + _expectedStructuredBytes = 0; + } + + bool GetIsInitialized() const noexcept { return _initialized; } + const SocketCommandMetadata& GetMetadata() const noexcept { return _metadata; } + void SetMetadata(SocketCommandMetadata metadata) { _metadata = std::move(metadata); } + void SetPolicy(SocketCommandPolicyHandler policy) { _policy = std::move(policy); } + void SetResultObserver(SocketCommandResultObserver observer) { _observer = std::move(observer); } + + bool Feed(const uint8_t* data, std::size_t size) { + if (!_initialized || data == nullptr) return false; + return _config.Mode == SocketCommandMode::Line + ? FeedLine(data, size) + : FeedStructured(data, size); + } + +private: + Command::CommandRegistry* _registry = nullptr; + SocketCommandSessionConfig _config; + SocketCommandMetadata _metadata; + SocketCommandWriteHandler _writer; + SocketCommandPolicyHandler _policy; + SocketCommandResultObserver _observer; + std::string _line; + std::vector _structured; + bool _discardUntilNewline = false; + uint32_t _expectedStructuredBytes = 0; + bool _initialized = false; + + static std::string Trim(std::string value) { + auto notSpace = [](unsigned char c) { return !std::isspace(c); }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), notSpace)); + value.erase(std::find_if(value.rbegin(), value.rend(), notSpace).base(), value.end()); + return value; + } + + Command::CommandResult Execute(SocketCommandInvocationContext& context) { + if (_registry == nullptr) return Command::CommandResult::Error("Socket Command session is not initialized"); + if (_policy) { + auto policyResult = _policy(context); + if (!policyResult.success) { + if (_observer) _observer(context, policyResult); + return policyResult; + } + } + auto result = _registry->Invoke(context.Invocation); + if (_observer) _observer(context, result); + return result; + } + + bool WriteLineResult(const Command::CommandResult& result) { + std::string response = result.success ? "OK " : "ERR "; + response += std::to_string(result.code); + if (!result.message.empty()) { + response.push_back(' '); + response += result.message; + } + response.push_back('\n'); + return _writer(reinterpret_cast(response.data()), response.size()); + } + + bool HandleLine(std::string line) { + line = Trim(std::move(line)); + if (line.empty() && _config.IgnoreEmptyLines) return true; + + SocketCommandInvocationContext context; + context.Metadata = _metadata; + ++context.Metadata.RequestID; + _metadata.RequestID = context.Metadata.RequestID; + context.Invocation.raw = line; + + std::string parseError; + auto tokens = Command::TextCommandParser::Tokenize(line, &parseError); + if (!parseError.empty()) return WriteLineResult(Command::CommandResult::Error(parseError)); + if (tokens.empty()) return WriteLineResult(Command::CommandResult::Error("No command supplied")); + + // Text Commands are still parsed/executed by ESPressio Command, while + // Sockets exposes transport metadata to the network policy layer. + context.Invocation.path = {tokens.front()}; + if (_policy) { + auto policyResult = _policy(context); + if (!policyResult.success) { + if (_observer) _observer(context, policyResult); + return WriteLineResult(policyResult); + } + } + auto result = _registry->Invoke(line); + if (_observer) _observer(context, result); + return WriteLineResult(result); + } + + bool FeedLine(const uint8_t* data, std::size_t size) { + bool success = true; + for (std::size_t i = 0; i < size; ++i) { + const char c = static_cast(data[i]); + if (c == '\r') continue; + if (c == '\n') { + if (_discardUntilNewline) { + _discardUntilNewline = false; + _line.clear(); + const bool wrote = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length")); + if (_config.DisconnectOnProtocolError) return false; + success = wrote && success; + continue; + } + std::string line = std::move(_line); + _line.clear(); + success = HandleLine(std::move(line)) && success; + continue; + } + if (_discardUntilNewline) continue; + if (_line.size() >= _config.MaximumRequestBytes) { + _line.clear(); + _discardUntilNewline = true; + continue; + } + _line.push_back(c); + } + return success; + } + + static uint32_t ReadFrameLength(const uint8_t* data) { + return (static_cast(data[0]) << 24) | + (static_cast(data[1]) << 16) | + (static_cast(data[2]) << 8) | + static_cast(data[3]); + } + + bool EmitStructuredError(uint64_t requestID, std::string message) { + SocketCommandResponse response; + response.RequestID = requestID; + response.Result = Command::CommandResult::Error(std::move(message)); + std::vector payload; + if (!SocketCommandProtocol::EncodeResponse(response, payload)) return false; + auto frame = SocketCommandProtocol::FrameStructuredPayload(payload); + return !frame.empty() && _writer(frame.data(), frame.size()); + } + + bool HandleStructuredFrame(const uint8_t* data, std::size_t size) { + SocketCommandInvocationContext context; + context.Metadata = _metadata; + if (!SocketCommandProtocol::DecodeRequest(data, size, context)) { + return EmitStructuredError(0, "Malformed structured Command request"); + } + _metadata.RequestID = context.Metadata.RequestID; + auto result = Execute(context); + SocketCommandResponse response; + response.RequestID = context.Metadata.RequestID; + response.Result = std::move(result); + std::vector payload; + if (!SocketCommandProtocol::EncodeResponse(response, payload)) return false; + auto frame = SocketCommandProtocol::FrameStructuredPayload(payload); + return !frame.empty() && _writer(frame.data(), frame.size()); + } + + bool FeedStructured(const uint8_t* data, std::size_t size) { + if (size > _config.MaximumRequestBytes + 4 && _structured.empty()) return false; + _structured.insert(_structured.end(), data, data + size); + bool success = true; + + while (true) { + if (_expectedStructuredBytes == 0) { + if (_structured.size() < 4) break; + _expectedStructuredBytes = ReadFrameLength(_structured.data()); + _structured.erase(_structured.begin(), _structured.begin() + 4); + if (_expectedStructuredBytes == 0 || _expectedStructuredBytes > _config.MaximumRequestBytes) { + _structured.clear(); + _expectedStructuredBytes = 0; + const bool wrote = EmitStructuredError(0, "Structured Command frame exceeds configured limit"); + return _config.DisconnectOnProtocolError ? false : wrote; + } + } + if (_structured.size() < _expectedStructuredBytes) break; + + const auto frameSize = static_cast(_expectedStructuredBytes); + success = HandleStructuredFrame(_structured.data(), frameSize) && success; + _structured.erase(_structured.begin(), _structured.begin() + frameSize); + _expectedStructuredBytes = 0; + } + + if (_structured.size() > _config.MaximumRequestBytes) { + _structured.clear(); + _expectedStructuredBytes = 0; + return false; + } + return success; + } +}; + +} diff --git a/src/ESPressio_SocketCommandTypes.hpp b/src/ESPressio_SocketCommandTypes.hpp new file mode 100644 index 0000000..c130b5d --- /dev/null +++ b/src/ESPressio_SocketCommandTypes.hpp @@ -0,0 +1,57 @@ +#pragma once + +#if !__has_include() +#error "ESPressio Socket Command integration requires ESPressio Command >= 0.2.0 < 1.0.0." +#endif + +#include +#include +#include +#include +#include +#include + +#include + +namespace ESPressio::Sockets { + +enum class SocketCommandMode : uint8_t { + Line = 0, + StructuredBinary = 1 +}; + +struct SocketCommandMetadata { + std::string Transport = "socket"; + std::string RemoteAddress; + uint16_t RemotePort = 0; + uint64_t SessionID = 0; + uint64_t RequestID = 0; +}; + +struct SocketCommandInvocationContext { + Command::CommandInvocation Invocation; + SocketCommandMetadata Metadata; +}; + +struct SocketCommandSessionConfig { + SocketCommandMode Mode = SocketCommandMode::Line; + std::size_t MaximumRequestBytes = 1024; + bool DisconnectOnProtocolError = false; + bool IgnoreEmptyLines = true; +}; + +struct SocketCommandResponse { + uint64_t RequestID = 0; + Command::CommandResult Result; +}; + +using SocketCommandWriteHandler = + std::function; + +using SocketCommandPolicyHandler = + std::function; + +using SocketCommandResultObserver = + std::function; + +} diff --git a/src/ESPressio_Sockets.hpp b/src/ESPressio_Sockets.hpp index e04e636..581fac1 100644 --- a/src/ESPressio_Sockets.hpp +++ b/src/ESPressio_Sockets.hpp @@ -20,6 +20,13 @@ * * ESPressio_SocketClockSynchronization.hpp * - * This keeps ESPressio Event/Serializable and ESPressio Timing dependencies - * opt-in at the consuming-code level. + * Command invocation is opt-in through: + * + * ESPressio_SocketCommandTypes.hpp + * ESPressio_SocketCommandProtocol.hpp + * ESPressio_SocketCommandSession.hpp + * ESPressio_TCPCommandServer.hpp + * + * This keeps ESPressio Event/Serializable, ESPressio Timing, and ESPressio + * Command dependencies opt-in at the consuming-code level. */ diff --git a/src/ESPressio_TCPCommandServer.hpp b/src/ESPressio_TCPCommandServer.hpp new file mode 100644 index 0000000..7f0b172 --- /dev/null +++ b/src/ESPressio_TCPCommandServer.hpp @@ -0,0 +1,196 @@ +#pragma once + +#if !__has_include() +#error "TCPCommandServer requires ESPressio Command >= 0.2.0 < 1.0.0." +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ESPressio_SocketCommandSession.hpp" +#include "ESPressio_SocketStreamHelpers.hpp" +#include "ESPressio_SocketTypes.hpp" +#include "ESPressio_SocketWorker.hpp" + +namespace ESPressio::Sockets { + +struct TCPCommandServerConfig { + uint16_t Port = 0; + std::size_t MaximumClients = ESPRESSIO_SOCKETS_MAX_TCP_CLIENTS; + SocketCommandSessionConfig Session; + SocketWorkerConfig Worker; +}; + +class TCPCommandServer final : private SocketWorker { +public: + TCPCommandServer() = default; + TCPCommandServer(const TCPCommandServer&) = delete; + TCPCommandServer& operator=(const TCPCommandServer&) = delete; + ~TCPCommandServer() override { Shutdown(); } + + bool Initialize( + const TCPCommandServerConfig& config, + Command::CommandRegistry& registry = Command::CommandRegistry::GetInstance() + ) { + if (_initialized) return true; + if (config.Port == 0 || config.MaximumClients == 0 || + config.MaximumClients > _clients.size() || + config.Session.MaximumRequestBytes == 0) return false; + + _config = config; + _registry = ®istry; + _server = std::make_unique(config.Port); + _server->begin(); + _server->setNoDelay(true); + + if (!StartWorker("ESPressioCmdTCP", config.Worker)) { + _server->end(); + _server.reset(); + _registry = nullptr; + return false; + } + _initialized = true; + return true; + } + + void Shutdown() { + if (!_initialized && _server == nullptr) return; + StopWorker(); + std::lock_guard lock(_clientsMutex); + for (auto& state : _clients) ResetClient(state); + if (_server) { + _server->end(); + _server.reset(); + } + _registry = nullptr; + _initialized = false; + } + + bool GetIsInitialized() const noexcept { return _initialized; } + + std::size_t GetConnectedClientCount() const { + std::lock_guard lock(_clientsMutex); + std::size_t count = 0; + for (const auto& state : _clients) { + if (state.Active && state.Client.connected()) ++count; + } + return count; + } + + void SetPolicy(SocketCommandPolicyHandler policy) { + std::lock_guard lock(_clientsMutex); + _policy = std::move(policy); + for (auto& state : _clients) if (state.Active) state.Session.SetPolicy(_policy); + } + + void SetResultObserver(SocketCommandResultObserver observer) { + std::lock_guard lock(_clientsMutex); + _observer = std::move(observer); + for (auto& state : _clients) if (state.Active) state.Session.SetResultObserver(_observer); + } + +protected: + void OnWorkerIteration() override { + std::lock_guard lock(_clientsMutex); + AcceptClientLocked(); + std::array buffer{}; + + for (std::size_t i = 0; i < _config.MaximumClients; ++i) { + auto& state = _clients[i]; + if (!state.Active) continue; + if (!state.Client.connected()) { + ResetClient(state); + continue; + } + + while (state.Client.available() > 0) { + const int count = state.Client.read(buffer.data(), buffer.size()); + if (count <= 0) break; + if (!state.Session.Feed(buffer.data(), static_cast(count))) { + if (_config.Session.DisconnectOnProtocolError) { + ResetClient(state); + break; + } + } + } + } + } + +private: + struct ClientState { + WiFiClient Client; + SocketCommandSession Session; + uint64_t ID = 0; + bool Active = false; + }; + + std::unique_ptr _server; + std::array _clients; + TCPCommandServerConfig _config; + Command::CommandRegistry* _registry = nullptr; + SocketCommandPolicyHandler _policy; + SocketCommandResultObserver _observer; + mutable std::mutex _clientsMutex; + uint64_t _nextSessionID = 1; + bool _initialized = false; + + static void ResetClient(ClientState& state) { + state.Session.Shutdown(); + state.Client.stop(); + state.ID = 0; + state.Active = false; + } + + void AcceptClientLocked() { + if (!_server || _registry == nullptr) return; + WiFiClient incoming = _server->available(); + if (!incoming) return; + + for (std::size_t i = 0; i < _config.MaximumClients; ++i) { + auto& state = _clients[i]; + if (state.Active && state.Client.connected()) continue; + ResetClient(state); + state.Client = incoming; + state.ID = _nextSessionID++; + if (_nextSessionID == 0) _nextSessionID = 1; + + SocketCommandMetadata metadata; + metadata.Transport = "tcp"; + metadata.RemoteAddress = std::string(state.Client.remoteIP().toString().c_str()); + metadata.RemotePort = state.Client.remotePort(); + metadata.SessionID = state.ID; + + ClientState* clientState = &state; + const bool initialized = state.Session.Initialize( + *_registry, + _config.Session, + std::move(metadata), + [clientState](const uint8_t* data, std::size_t size) { + if (!clientState->Active || !clientState->Client.connected()) return false; + return WriteAll(clientState->Client, data, size); + } + ); + + if (!initialized) { + ResetClient(state); + return; + } + state.Session.SetPolicy(_policy); + state.Session.SetResultObserver(_observer); + state.Active = true; + return; + } + + incoming.stop(); + } +}; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..79d930d --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.16) +project(ESPressioSocketsTests LANGUAGES CXX) + +enable_testing() + +add_executable(test_core_include test_core_include.cpp) +target_compile_features(test_core_include PRIVATE cxx_std_17) +target_compile_options(test_core_include PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_include_directories(test_core_include PRIVATE stubs ../src) +add_test(NAME CoreWithoutCommand COMMAND test_core_include) + +if(NOT ESPRESSIO_COMMAND_INCLUDE_DIR) + message(FATAL_ERROR "ESPRESSIO_COMMAND_INCLUDE_DIR is required for SocketCommand tests") +endif() + +add_executable(test_socket_command test_socket_command.cpp) +target_compile_features(test_socket_command PRIVATE cxx_std_17) +target_compile_options(test_socket_command PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_include_directories(test_socket_command PRIVATE ../src ${ESPRESSIO_COMMAND_INCLUDE_DIR}) +add_test(NAME SocketCommand COMMAND test_socket_command) + +if(ESPRESSIO_TIMING_INCLUDE_DIR AND ESPRESSIO_UNITS_INCLUDE_DIR AND ESPRESSIO_OBSERVABLE_INCLUDE_DIR) + add_executable(test_clock_sync_protocol test_clock_sync_protocol.cpp) + target_compile_features(test_clock_sync_protocol PRIVATE cxx_std_17) + target_compile_options(test_clock_sync_protocol PRIVATE -Wall -Wextra -Wpedantic -Werror) + target_include_directories(test_clock_sync_protocol PRIVATE + stubs + ../src + ../src/timing + ${ESPRESSIO_TIMING_INCLUDE_DIR} + ${ESPRESSIO_UNITS_INCLUDE_DIR} + ${ESPRESSIO_OBSERVABLE_INCLUDE_DIR} + ) + add_test(NAME ClockSynchronizationProtocol COMMAND test_clock_sync_protocol) +endif() diff --git a/tests/stubs/Arduino.h b/tests/stubs/Arduino.h new file mode 100644 index 0000000..164df3e --- /dev/null +++ b/tests/stubs/Arduino.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +using String = std::string; + +inline uint32_t millis() { return 1234; } + +class Print { +public: + virtual ~Print() = default; + virtual std::size_t write(uint8_t value) = 0; + virtual std::size_t write(const uint8_t* data, std::size_t size) { + std::size_t written = 0; + for (std::size_t i = 0; i < size; ++i) written += write(data[i]); + return written; + } + std::size_t print(const char* value) { + if (value == nullptr) return 0; + return write(reinterpret_cast(value), std::strlen(value)); + } + std::size_t print(const std::string& value) { + return write(reinterpret_cast(value.data()), value.size()); + } + std::size_t print(char value) { return write(static_cast(value)); } + std::size_t print(int value) { return print(std::to_string(value)); } + std::size_t print(unsigned int value) { return print(std::to_string(value)); } + std::size_t print(long value) { return print(std::to_string(value)); } + std::size_t print(unsigned long value) { return print(std::to_string(value)); } + std::size_t println() { return print("\n"); } + template std::size_t println(const TValue& value) { return print(value) + println(); } +}; + +class Stream : public Print { +public: + virtual int available() = 0; + virtual int read() = 0; +}; diff --git a/tests/stubs/IPAddress.h b/tests/stubs/IPAddress.h new file mode 100644 index 0000000..3457968 --- /dev/null +++ b/tests/stubs/IPAddress.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +class IPAddress { +public: + constexpr IPAddress() = default; + constexpr IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) + : value_((static_cast(a) << 24) | + (static_cast(b) << 16) | + (static_cast(c) << 8) | + static_cast(d)) {} + + constexpr explicit operator uint32_t() const noexcept { return value_; } + constexpr bool operator==(const IPAddress& other) const noexcept { return value_ == other.value_; } + constexpr bool operator!=(const IPAddress& other) const noexcept { return value_ != other.value_; } + +private: + uint32_t value_ = 0; +}; diff --git a/tests/stubs/freertos/FreeRTOS.h b/tests/stubs/freertos/FreeRTOS.h new file mode 100644 index 0000000..17628ff --- /dev/null +++ b/tests/stubs/freertos/FreeRTOS.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +using UBaseType_t = unsigned int; +using BaseType_t = int; + +#ifndef tskNO_AFFINITY +#define tskNO_AFFINITY (-1) +#endif diff --git a/tests/stubs/freertos/task.h b/tests/stubs/freertos/task.h new file mode 100644 index 0000000..fab70a5 --- /dev/null +++ b/tests/stubs/freertos/task.h @@ -0,0 +1,3 @@ +#pragma once + +#include "FreeRTOS.h" diff --git a/tests/test_core_include.cpp b/tests/test_core_include.cpp new file mode 100644 index 0000000..8279a72 --- /dev/null +++ b/tests/test_core_include.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + ESPressio::Sockets::SocketWorkerConfig config; + return config.StackSize == 0 ? 1 : 0; +} diff --git a/tests/test_socket_command.cpp b/tests/test_socket_command.cpp new file mode 100644 index 0000000..7839c07 --- /dev/null +++ b/tests/test_socket_command.cpp @@ -0,0 +1,288 @@ +#include +#include +#include +#include +#include + +#include + +using namespace ESPressio; + +static std::string AsString(const std::vector& data) { + return std::string(data.begin(), data.end()); +} + +static uint32_t FrameLength(const std::vector& frame) { + assert(frame.size() >= 4); + return (static_cast(frame[0]) << 24) | + (static_cast(frame[1]) << 16) | + (static_cast(frame[2]) << 8) | + static_cast(frame[3]); +} + +static Sockets::SocketCommandResponse DecodeFramedResponse(const std::vector& frame) { + assert(frame.size() >= 4); + const auto length = FrameLength(frame); + assert(length == frame.size() - 4); + Sockets::SocketCommandResponse response; + assert(Sockets::SocketCommandProtocol::DecodeResponse(frame.data() + 4, length, response)); + return response; +} + +int main() { + { + Command::CommandRegistry registry; + auto& echo = registry.Command("echo"); + echo.Parameter("value"); + echo.OnExecute([](const Command::CommandContext& context) { + return Command::CommandResult::Ok(context.Get("value")); + }); + + std::vector output; + Sockets::SocketCommandSession session; + Sockets::SocketCommandSessionConfig config; + config.Mode = Sockets::SocketCommandMode::Line; + config.MaximumRequestBytes = 64; + Sockets::SocketCommandMetadata metadata; + metadata.Transport = "tcp"; + metadata.RemoteAddress = "192.0.2.10"; + metadata.RemotePort = 4444; + metadata.SessionID = 7; + assert(session.Initialize(registry, config, metadata, + [&](const uint8_t* data, std::size_t size) { + output.insert(output.end(), data, data + size); + return true; + })); + + const char* first = "echo hel"; + assert(session.Feed(reinterpret_cast(first), std::strlen(first))); + assert(output.empty()); + const char* second = "lo\r\n"; + assert(session.Feed(reinterpret_cast(second), std::strlen(second))); + assert(AsString(output) == "OK 0 hello\n"); + + output.clear(); + const char* multiple = "echo one\necho two\n"; + assert(session.Feed(reinterpret_cast(multiple), std::strlen(multiple))); + assert(AsString(output) == "OK 0 one\nOK 0 two\n"); + + output.clear(); + const char* quoted = "echo \"hello world\"\n"; + assert(session.Feed(reinterpret_cast(quoted), std::strlen(quoted))); + assert(AsString(output) == "OK 0 hello world\n"); + + output.clear(); + const char* blank = "\n\r\n"; + assert(session.Feed(reinterpret_cast(blank), std::strlen(blank))); + assert(output.empty()); + + bool policyCalled = false; + bool observerCalled = false; + session.SetPolicy([&](const Sockets::SocketCommandInvocationContext& context) { + policyCalled = true; + assert(context.Metadata.Transport == "tcp"); + assert(context.Metadata.RemoteAddress == "192.0.2.10"); + assert(context.Metadata.RemotePort == 4444); + assert(context.Metadata.SessionID == 7); + assert(context.Invocation.raw == "echo denied"); + return Command::CommandResult::Error("remote policy denied", 403); + }); + session.SetResultObserver([&](const Sockets::SocketCommandInvocationContext& context, const Command::CommandResult& result) { + observerCalled = true; + assert(context.Metadata.RequestID > 0); + assert(!result.success); + assert(result.code == 403); + }); + output.clear(); + const char* denied = "echo denied\n"; + assert(session.Feed(reinterpret_cast(denied), std::strlen(denied))); + assert(policyCalled); + assert(observerCalled); + assert(AsString(output) == "ERR 403 remote policy denied\n"); + + session.SetPolicy({}); + session.SetResultObserver({}); + output.clear(); + const char* unknown = "does-not-exist\n"; + assert(session.Feed(reinterpret_cast(unknown), std::strlen(unknown))); + assert(AsString(output).find("ERR 1 Unknown command") == 0); + } + + { + Command::CommandRegistry registry; + registry.Command("ping").OnExecute([](const Command::CommandContext&) { + return Command::CommandResult::Ok("pong"); + }); + + Sockets::SocketCommandSessionConfig config; + config.MaximumRequestBytes = 8; + std::vector output; + Sockets::SocketCommandSession session; + assert(session.Initialize(registry, config, {}, [&](const uint8_t* data, std::size_t size) { + output.insert(output.end(), data, data + size); + return true; + })); + + const char* oversized = "123456789012345\nping\n"; + assert(session.Feed(reinterpret_cast(oversized), std::strlen(oversized))); + assert(AsString(output) == "ERR 1 Command exceeds maximum request length\nOK 0 pong\n"); + } + + { + Command::CommandRegistry registry; + auto& add = registry.Command("math").Command("add"); + add.Parameter("left"); + add.Parameter("right"); + add.OnExecute([](const Command::CommandContext& context) { + const int result = context.Get("left") + context.Get("right"); + return Command::CommandResult::Ok(std::to_string(result)); + }); + + Sockets::SocketCommandInvocationContext request; + request.Metadata.RequestID = 42; + request.Invocation.path = {"math", "add"}; + request.Invocation.positional = {"20", "22"}; + request.Invocation.named["unused"] = "metadata-like-value"; + request.Invocation.raw = "machine request"; + + std::vector encoded; + assert(Sockets::SocketCommandProtocol::EncodeRequest(request, encoded)); + Sockets::SocketCommandInvocationContext decoded; + assert(Sockets::SocketCommandProtocol::DecodeRequest(encoded.data(), encoded.size(), decoded)); + assert(decoded.Metadata.RequestID == 42); + assert(decoded.Invocation.path == request.Invocation.path); + assert(decoded.Invocation.positional == request.Invocation.positional); + assert(decoded.Invocation.named == request.Invocation.named); + assert(decoded.Invocation.raw == request.Invocation.raw); + + // Remove the deliberately unknown named argument before execution. + request.Invocation.named.clear(); + encoded.clear(); + assert(Sockets::SocketCommandProtocol::EncodeRequest(request, encoded)); + auto framed = Sockets::SocketCommandProtocol::FrameStructuredPayload(encoded); + + std::vector output; + Sockets::SocketCommandSession session; + Sockets::SocketCommandSessionConfig config; + config.Mode = Sockets::SocketCommandMode::StructuredBinary; + config.MaximumRequestBytes = 512; + Sockets::SocketCommandMetadata metadata; + metadata.Transport = "tcp"; + metadata.SessionID = 99; + assert(session.Initialize(registry, config, metadata, [&](const uint8_t* data, std::size_t size) { + output.insert(output.end(), data, data + size); + return true; + })); + + bool policyCalled = false; + session.SetPolicy([&](const Sockets::SocketCommandInvocationContext& context) { + policyCalled = true; + assert(context.Metadata.Transport == "tcp"); + assert(context.Metadata.SessionID == 99); + assert(context.Metadata.RequestID >= 42 && context.Metadata.RequestID <= 44); + assert(context.Invocation.path.size() == 2); + return Command::CommandResult::Ok(); + }); + + assert(session.Feed(framed.data(), 2)); + assert(output.empty()); + assert(session.Feed(framed.data() + 2, 3)); + assert(output.empty()); + assert(session.Feed(framed.data() + 5, framed.size() - 5)); + assert(policyCalled); + + auto response = DecodeFramedResponse(output); + assert(response.RequestID == 42); + assert(response.Result.success); + assert(response.Result.code == 0); + assert(response.Result.message == "42"); + + // Multiple framed requests in one receive buffer. + output.clear(); + request.Metadata.RequestID = 43; + request.Invocation.positional = {"1", "2"}; + encoded.clear(); + assert(Sockets::SocketCommandProtocol::EncodeRequest(request, encoded)); + auto frame2 = Sockets::SocketCommandProtocol::FrameStructuredPayload(encoded); + request.Metadata.RequestID = 44; + request.Invocation.positional = {"3", "4"}; + encoded.clear(); + assert(Sockets::SocketCommandProtocol::EncodeRequest(request, encoded)); + auto frame3 = Sockets::SocketCommandProtocol::FrameStructuredPayload(encoded); + std::vector combined = frame2; + combined.insert(combined.end(), frame3.begin(), frame3.end()); + assert(session.Feed(combined.data(), combined.size())); + assert(!output.empty()); + + const auto firstLength = static_cast(FrameLength(output)) + 4; + assert(firstLength < output.size()); + std::vector firstResponse(output.begin(), output.begin() + firstLength); + std::vector secondResponse(output.begin() + firstLength, output.end()); + assert(DecodeFramedResponse(firstResponse).RequestID == 43); + assert(DecodeFramedResponse(firstResponse).Result.message == "3"); + assert(DecodeFramedResponse(secondResponse).RequestID == 44); + assert(DecodeFramedResponse(secondResponse).Result.message == "7"); + } + + { + // Per-session framing state must be independent. + Command::CommandRegistry registry; + auto& echo = registry.Command("echo"); + echo.Parameter("value"); + echo.OnExecute([](const Command::CommandContext& context) { + return Command::CommandResult::Ok(context.Get("value")); + }); + Sockets::SocketCommandSessionConfig config; + std::vector outA, outB; + Sockets::SocketCommandSession a, b; + assert(a.Initialize(registry, config, {}, [&](const uint8_t* d, std::size_t n){ outA.insert(outA.end(), d, d+n); return true; })); + assert(b.Initialize(registry, config, {}, [&](const uint8_t* d, std::size_t n){ outB.insert(outB.end(), d, d+n); return true; })); + const char* pa = "echo A"; + const char* pb = "echo B\n"; + assert(a.Feed(reinterpret_cast(pa), std::strlen(pa))); + assert(b.Feed(reinterpret_cast(pb), std::strlen(pb))); + assert(outA.empty()); + assert(AsString(outB) == "OK 0 B\n"); + const char nl = '\n'; + assert(a.Feed(reinterpret_cast(&nl), 1)); + assert(AsString(outA) == "OK 0 A\n"); + } + + { + Command::CommandRegistry registry; + registry.Command("ping").OnExecute([](const Command::CommandContext&) { + return Command::CommandResult::Ok("pong"); + }); + Sockets::SocketCommandSessionConfig config; + config.MaximumRequestBytes = 4; + config.DisconnectOnProtocolError = true; + std::vector output; + Sockets::SocketCommandSession session; + assert(session.Initialize(registry, config, {}, [&](const uint8_t* d, std::size_t n) { + output.insert(output.end(), d, d + n); + return true; + })); + const char* oversized = "12345\n"; + assert(!session.Feed(reinterpret_cast(oversized), std::strlen(oversized))); + assert(AsString(output) == "ERR 1 Command exceeds maximum request length\n"); + } + + { + // Protocol rejects truncation, invalid magic/version and malformed response state. + Sockets::SocketCommandInvocationContext request; + request.Metadata.RequestID = 1; + request.Invocation.path = {"x"}; + std::vector encoded; + assert(Sockets::SocketCommandProtocol::EncodeRequest(request, encoded)); + Sockets::SocketCommandInvocationContext decoded; + assert(!Sockets::SocketCommandProtocol::DecodeRequest(encoded.data(), encoded.size() - 1, decoded)); + auto badMagic = encoded; + badMagic[0] ^= 0xFF; + assert(!Sockets::SocketCommandProtocol::DecodeRequest(badMagic.data(), badMagic.size(), decoded)); + auto badVersion = encoded; + badVersion[4] = 99; + assert(!Sockets::SocketCommandProtocol::DecodeRequest(badVersion.data(), badVersion.size(), decoded)); + } + + return 0; +}