From e15f901faa2594d9d7c4cbfd4d4f5da0a6d1d1c2 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:15:35 +0200 Subject: [PATCH 01/46] feat: add socket Command integration types --- src/ESPressio_SocketCommandTypes.hpp | 57 ++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/ESPressio_SocketCommandTypes.hpp 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; + +} From dd608627e63ff4bf02a76ba08cdf54f79aefe60c Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:16:15 +0200 Subject: [PATCH 02/46] feat: add socket Command structured protocol --- src/ESPressio_SocketCommandProtocol.hpp | 243 ++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/ESPressio_SocketCommandProtocol.hpp 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; +} + +} + +} From de58251ecc63481bf5d693f2c05e227769b00b25 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:16:46 +0200 Subject: [PATCH 03/46] feat: add host-testable socket Command session --- src/ESPressio_SocketCommandSession.hpp | 230 +++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/ESPressio_SocketCommandSession.hpp diff --git a/src/ESPressio_SocketCommandSession.hpp b/src/ESPressio_SocketCommandSession.hpp new file mode 100644 index 0000000..a35c157 --- /dev/null +++ b/src/ESPressio_SocketCommandSession.hpp @@ -0,0 +1,230 @@ +#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")); + + // Preserve the full text path/arguments behavior by letting Command parse the raw line. + 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(); + success = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length")) && 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; + return EmitStructuredError(0, "Structured Command frame exceeds configured limit"); + } + } + 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; + } +}; + +} From db370132be23a3b099b5dded3e19c3394fa7331d Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:17:34 +0200 Subject: [PATCH 04/46] feat: add TCP Command server --- src/ESPressio_TCPCommandServer.hpp | 196 +++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/ESPressio_TCPCommandServer.hpp 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(); + } +}; + +} From 5e3dd92e83cad9cb60dfab57e9e476903ce883f4 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:19:23 +0200 Subject: [PATCH 05/46] test: add comprehensive socket Command protocol coverage --- tests/test_socket_command.cpp | 262 ++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/test_socket_command.cpp diff --git a/tests/test_socket_command.cpp b/tests/test_socket_command.cpp new file mode 100644 index 0000000..b82f301 --- /dev/null +++ b/tests/test_socket_command.cpp @@ -0,0 +1,262 @@ +#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"); + } + + { + 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); + 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"); + } + + { + // 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; +} From df31d95314c3c6a62d9a303e6cd84bac43fc0416 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:20:03 +0200 Subject: [PATCH 06/46] test: add host test build for Sockets --- tests/CMakeLists.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/CMakeLists.txt diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..c627556 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.16) +project(ESPressioSocketsTests LANGUAGES CXX) + +enable_testing() + +if(NOT ESPRESSIO_COMMAND_INCLUDE_DIR) + message(FATAL_ERROR "ESPRESSIO_COMMAND_INCLUDE_DIR is required") +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 + ../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() From ed9b3858d26188feff960b7ba24e590e136b5235 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:20:22 +0200 Subject: [PATCH 07/46] ci: add Sockets host test workflow --- .github/workflows/host-tests.yml | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/host-tests.yml 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 From 4f55cfea1177cb48781f7e2efc427760b1cf58e3 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:21:12 +0200 Subject: [PATCH 08/46] chore: add temporary Command feature validation --- .../workflows/validate-command-feature.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/validate-command-feature.yml diff --git a/.github/workflows/validate-command-feature.yml b/.github/workflows/validate-command-feature.yml new file mode 100644 index 0000000..5e61077 --- /dev/null +++ b/.github/workflows/validate-command-feature.yml @@ -0,0 +1,63 @@ +name: Validate Command Feature + +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/validate-command-feature.yml'] + +permissions: + contents: write + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Command + ref: 0.2.0 + path: deps/ESPressio-Command + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Timing + ref: 2.2.2 + path: deps/ESPressio-Timing + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Units + ref: 0.2.1 + path: deps/ESPressio-Units + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + - name: Validate and report + shell: bash + run: | + set +e + { + echo '=== CONFIGURE ===' + 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" + c1=$? + echo "configure_exit=$c1" + if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi + echo "build_exit=$c2" + if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi + echo "test_exit=$c3" + } > validation.txt 2>&1 + cat validation.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p .ci + mv validation.txt .ci/command-feature-validation.txt + git add -- .ci/command-feature-validation.txt + git commit -m "chore: record Command feature validation" + git push origin HEAD:feature/command-socket-integration From 2125056af29dbc7b552db730f176d6e9c87fca9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:21:30 +0000 Subject: [PATCH 09/46] chore: record Command feature validation --- .ci/command-feature-validation.txt | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .ci/command-feature-validation.txt diff --git a/.ci/command-feature-validation.txt b/.ci/command-feature-validation.txt new file mode 100644 index 0000000..116380a --- /dev/null +++ b/.ci/command-feature-validation.txt @@ -0,0 +1,31 @@ +=== CONFIGURE === +-- The CXX compiler identification is GNU 13.3.0 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/bin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (4.0s) +-- Generating done (0.0s) +-- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +configure_exit=0 +[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o +[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o +In file included from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Units/src/ESPressio_Time.hpp:3, + from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_ClockTypes.hpp:4, + from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_ClockSynchronization.hpp:5, + from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_IClockSynchronizationTarget.hpp:3, + from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationProtocol.hpp:9, + from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/test_clock_sync_protocol.cpp:4: +/home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Units/src/ESPressio_Unit.hpp:3:10: fatal error: Arduino.h: No such file or directory + 3 | #include + | ^~~~~~~~~~~ +compilation terminated. +gmake[2]: *** [CMakeFiles/test_clock_sync_protocol.dir/build.make:79: CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o] Error 1 +gmake[1]: *** [CMakeFiles/Makefile2:122: CMakeFiles/test_clock_sync_protocol.dir/all] Error 2 +gmake[1]: *** Waiting for unfinished jobs.... +[ 75%] Linking CXX executable test_socket_command +[ 75%] Built target test_socket_command +gmake: *** [Makefile:101: all] Error 2 +build_exit=2 +test_exit=99 From fcb4097c94d04e15bfeab5fd2352b0051a3efaec Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:22:41 +0200 Subject: [PATCH 10/46] test: add minimal Arduino host stub --- tests/stubs/Arduino.h | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/stubs/Arduino.h 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; +}; From 68c750c36f499f2b45b8776c1b9feba63d9c11d3 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:23:01 +0200 Subject: [PATCH 11/46] test: include Arduino host stubs --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c627556..747db45 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,6 +18,7 @@ if(ESPRESSIO_TIMING_INCLUDE_DIR AND ESPRESSIO_UNITS_INCLUDE_DIR AND ESPRESSIO_OB 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} From 2c4098154617de7398a561940f7bf5a400a751bc Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:24:21 +0200 Subject: [PATCH 12/46] chore: patch and revalidate Command session policy --- .../workflows/validate-command-feature.yml | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate-command-feature.yml b/.github/workflows/validate-command-feature.yml index 5e61077..1621d43 100644 --- a/.github/workflows/validate-command-feature.yml +++ b/.github/workflows/validate-command-feature.yml @@ -15,6 +15,25 @@ jobs: - uses: actions/checkout@v4 with: ref: feature/command-socket-integration + + - name: Apply policy-path correction + run: | + python3 - <<'PY' + from pathlib import Path + p = Path('src/ESPressio_SocketCommandSession.hpp') + s = p.read_text() + old = ''' // Preserve the full text path/arguments behavior by letting Command parse the raw line.\n auto result = _registry->Invoke(line);\n if (_observer) _observer(context, result);\n return WriteLineResult(result);''' + new = ''' // Text Commands are still parsed/executed by ESPressio Command, while\n // Sockets exposes transport metadata to the network policy layer.\n context.Invocation.path = {tokens.front()};\n if (_policy) {\n auto policyResult = _policy(context);\n if (!policyResult.success) {\n if (_observer) _observer(context, policyResult);\n return WriteLineResult(policyResult);\n }\n }\n auto result = _registry->Invoke(line);\n if (_observer) _observer(context, result);\n return WriteLineResult(result);''' + if old not in s: + raise SystemExit('policy patch target not found') + p.write_text(s.replace(old, new, 1)) + PY + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- src/ESPressio_SocketCommandSession.hpp + git commit -m "fix: apply socket policy to line Commands" || true + git push origin HEAD:feature/command-socket-integration + - uses: actions/checkout@v4 with: repository: Flowduino/ESPressio-Command @@ -35,6 +54,7 @@ jobs: repository: Flowduino/ESPressio-Observable ref: 3.0.1 path: deps/ESPressio-Observable + - name: Validate and report shell: bash run: | @@ -54,10 +74,8 @@ jobs: echo "test_exit=$c3" } > validation.txt 2>&1 cat validation.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" mkdir -p .ci mv validation.txt .ci/command-feature-validation.txt git add -- .ci/command-feature-validation.txt - git commit -m "chore: record Command feature validation" + git commit -m "chore: record Command feature validation" || true git push origin HEAD:feature/command-socket-integration From afe211ef822ee54ff2b9f5f1e71f7b1ae67708d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:24:27 +0000 Subject: [PATCH 13/46] fix: apply socket policy to line Commands --- src/ESPressio_SocketCommandSession.hpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ESPressio_SocketCommandSession.hpp b/src/ESPressio_SocketCommandSession.hpp index a35c157..71a5eb8 100644 --- a/src/ESPressio_SocketCommandSession.hpp +++ b/src/ESPressio_SocketCommandSession.hpp @@ -126,7 +126,16 @@ class SocketCommandSession final { if (!parseError.empty()) return WriteLineResult(Command::CommandResult::Error(parseError)); if (tokens.empty()) return WriteLineResult(Command::CommandResult::Error("No command supplied")); - // Preserve the full text path/arguments behavior by letting Command parse the raw line. + // 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); From 678ddea9e8c81a05a529aa4cd48cfad1a72eff62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:24:36 +0000 Subject: [PATCH 14/46] chore: record Command feature validation --- .ci/command-feature-validation.txt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.ci/command-feature-validation.txt b/.ci/command-feature-validation.txt index 116380a..d901b3f 100644 --- a/.ci/command-feature-validation.txt +++ b/.ci/command-feature-validation.txt @@ -5,21 +5,17 @@ -- Check for working CXX compiler: /usr/bin/c++ - skipped -- Detecting CXX compile features -- Detecting CXX compile features - done --- Configuring done (4.0s) +-- Configuring done (1.9s) -- Generating done (0.0s) -- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build configure_exit=0 [ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o [ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o -In file included from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Units/src/ESPressio_Time.hpp:3, - from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_ClockTypes.hpp:4, - from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_ClockSynchronization.hpp:5, - from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Timing/src/ESPressio_IClockSynchronizationTarget.hpp:3, - from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationProtocol.hpp:9, +In file included from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationProtocol.hpp:12, from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/test_clock_sync_protocol.cpp:4: -/home/runner/work/ESPressio-Sockets/ESPressio-Sockets/deps/ESPressio-Units/src/ESPressio_Unit.hpp:3:10: fatal error: Arduino.h: No such file or directory - 3 | #include - | ^~~~~~~~~~~ +/home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationTypes.hpp:4:10: fatal error: IPAddress.h: No such file or directory + 4 | #include + | ^~~~~~~~~~~~~ compilation terminated. gmake[2]: *** [CMakeFiles/test_clock_sync_protocol.dir/build.make:79: CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o] Error 1 gmake[1]: *** [CMakeFiles/Makefile2:122: CMakeFiles/test_clock_sync_protocol.dir/all] Error 2 From 080796c08ea73a2e8387917b2cd8855a677eec61 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:25:42 +0200 Subject: [PATCH 15/46] chore: finalize Command integration release metadata --- .../workflows/finalize-command-feature.yml | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 .github/workflows/finalize-command-feature.yml diff --git a/.github/workflows/finalize-command-feature.yml b/.github/workflows/finalize-command-feature.yml new file mode 100644 index 0000000..9d62c1b --- /dev/null +++ b/.github/workflows/finalize-command-feature.yml @@ -0,0 +1,403 @@ +name: Finalize Command Feature + +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/finalize-command-feature.yml'] + +permissions: + contents: write + +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + + - name: Update release metadata and documentation + run: | + python3 - <<'PY' + from pathlib import Path + import json + + # library.json + p = Path('library.json') + data = json.loads(p.read_text()) + data['version'] = '0.3.0' + data['description'] = 'Socket-based ESPressio transports, Command invocation adapters, and Timing synchronization providers for ESP32.' + data['keywords'] = 'esp32,sockets,udp,tcp,tls,websocket,mqtt,event,command,cli,transport,network,timing,clock,synchronization,sntp,espressio' + p.write_text(json.dumps(data, indent=4) + '\n') + + # library.properties + p = Path('library.properties') + s = p.read_text() + s = s.replace('version=0.2.3', 'version=0.3.0') + s = s.replace('sentence=Socket-based Event Transport and System Clock synchronization implementations for the ESPressio ecosystem.', + 'sentence=Socket-based Event Transport, Command invocation, and System Clock synchronization for the ESPressio ecosystem.') + s = s.replace('paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports plus opt-in ESPressio Timing System Clock synchronization over UDP, TCP, WebSocket and SNTP.', + 'paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports, opt-in TCP ESPressio Command invocation, and opt-in ESPressio Timing System Clock synchronization.') + p.write_text(s) + + # component.mk + p = Path('component.mk') + s = p.read_text() + s = s.replace('ESPRESSIO_SOCKETS_VERSION_MINOR=2', 'ESPRESSIO_SOCKETS_VERSION_MINOR=3') + s = s.replace('ESPRESSIO_SOCKETS_VERSION_PATCH=3', 'ESPRESSIO_SOCKETS_VERSION_PATCH=0') + s = s.replace('ESPRESSIO_SOCKETS_VERSION_STRING=\\"0.2.3\\"', 'ESPRESSIO_SOCKETS_VERSION_STRING=\\"0.3.0\\"') + p.write_text(s) + + # umbrella guidance (do not batch-include optional Command dependency) + p = Path('src/ESPressio_Sockets.hpp') + s = p.read_text() + marker = ''' * Timing synchronization is likewise opt-in through:\n *\n * ESPressio_SocketClockSynchronization.hpp\n *\n * This keeps ESPressio Event/Serializable and ESPressio Timing dependencies\n * opt-in at the consuming-code level.\n''' + replacement = ''' * Timing synchronization is likewise opt-in through:\n *\n * ESPressio_SocketClockSynchronization.hpp\n *\n * Command invocation is opt-in through:\n *\n * ESPressio_SocketCommandSession.hpp\n * ESPressio_TCPCommandServer.hpp\n *\n * This keeps ESPressio Event/Serializable, ESPressio Timing, and ESPressio\n * Command dependencies opt-in at the consuming-code level.\n''' + if marker in s: + s = s.replace(marker, replacement, 1) + p.write_text(s) + + # changelog + p = Path('CHANGELOG.md') + s = p.read_text() + entry = '''## 0.3.0 — 2026-08-20\n\n### Added\n- Added opt-in ESPressio Command 0.2.x integration for remote Command invocation over sockets.\n- Added host-testable `SocketCommandSession` with line-oriented and structured-binary request modes.\n- Added `TCPCommandServer` with isolated per-client Command sessions.\n- Added request/connection metadata, policy hooks, result observers, correlation IDs, bounded request handling, and structured request/response framing.\n- Added a TCP Command server example and comprehensive host tests for Command framing, dispatch, validation, session isolation, policy and error paths.\n- Added a permanent GitHub Actions host-test workflow pinned to released ESPressio dependencies.\n\n### Changed\n- Updated package/component version metadata to 0.3.0.\n- Updated README and ESPressio dependency documentation for optional Command integration.\n- Expanded host regression testing to retain coverage of the existing socket clock-synchronization protocol.\n\n### Compatibility\n- Core ESPressio Sockets remains independent of ESPressio Command.\n- Existing Event Transport and Timing synchronization APIs remain source-compatible.\n- ESPressio Command is required only when Command integration headers are selected.\n\n''' + if '## 0.3.0' not in s: + s = s.replace('# Changelog\n\n', '# Changelog\n\n' + entry, 1) + p.write_text(s) + + # README current version and Command section + p = Path('README.md') + s = p.read_text() + s = s.replace('The current repository version is **0.2.3**.', 'The current repository version is **0.3.0**.', 1) + s = s.replace('ESPressio Sockets `0.2.3` targets', 'ESPressio Sockets `0.3.0` targets', 1) + command_section = r''' + +# Command Invocation over Sockets + +Version 0.3.0 adds opt-in integration with **ESPressio Command >= 0.2.0 < 1.0.0**. + +Core Sockets does **not** require ESPressio Command. Include Command integration only where remote Command invocation is required: + +```cpp +#include +``` + +PlatformIO: + +```ini +lib_deps = + flowduino/ESPressio-Sockets@^0.3.0 + flowduino/ESPressio-Command@^0.2.0 +``` + +The dependency direction remains: + +```text +ESPressio Sockets core + -> no Command dependency + +Socket Command integration + - - -> ESPressio Command >= 0.2.0 < 1.0.0 +``` + +## Command 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 +``` + +`SocketCommandSession` is transport-neutral at the byte-stream boundary and is host-testable independently of Wi-Fi. `TCPCommandServer` binds an independent session to each accepted `WiFiClient`. + +## Line-oriented mode + +Line mode is suitable for interactive clients such as `nc`, telnet-style terminals, or simple custom controllers: + +```text +gpio write 2 high +system status +``` + +Each newline-delimited request is resolved by ESPressio Command and produces one response: + +```text +OK 0 System OK +ERR 1 Unknown command 'example' +``` + +Both LF and CRLF input are accepted. Quoting and escaping are delegated to ESPressio Command's text parser. + +Example configuration: + +```cpp +#include +#include + +using namespace ESPressio; + +Sockets::TCPCommandServer commandServer; + +void setup() { + auto& registry = Command::CommandRegistry::GetInstance(); + + registry.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; + + commandServer.Initialize(config, registry); +} +``` + +The consuming application remains responsible for establishing Wi-Fi/network connectivity before initializing the server. + +## Structured-binary mode + +Machine-to-machine clients can avoid manufacturing command-line text by sending a structured `CommandInvocation` representation. + +The version-1 request carries: + +```text +request/correlation ID +Command path +positional parameters +named parameters +raw caller metadata string +``` + +The response carries: + +```text +matching request/correlation ID +success/failure +CommandResult code +CommandResult message +``` + +Structured payloads use an explicit 32-bit length prefix and an ESPressio-owned binary format. This mode intentionally does **not** require ESPressio Serializable or JSON. + +Helpers are exposed through: + +```cpp +#include +``` + +including: + +```cpp +SocketCommandProtocol::EncodeRequest(...) +SocketCommandProtocol::DecodeRequest(...) +SocketCommandProtocol::EncodeResponse(...) +SocketCommandProtocol::DecodeResponse(...) +SocketCommandProtocol::FrameStructuredPayload(...) +``` + +## Per-client session isolation + +`TCPCommandServer` allocates an independent `SocketCommandSession` for every connected client. Partial lines, structured frame state, request IDs, and protocol errors therefore cannot leak between clients. + +The maximum number of clients remains bounded by both `MaximumClients` and `ESPRESSIO_SOCKETS_MAX_TCP_CLIENTS`. + +## Request limits and protocol errors + +`SocketCommandSessionConfig` provides: + +```text +Mode +MaximumRequestBytes +DisconnectOnProtocolError +IgnoreEmptyLines +``` + +Input accumulation is bounded by `MaximumRequestBytes`. Oversized line requests are discarded through the next newline and the session then recovers for subsequent commands. Structured frames declare their length before payload processing and are rejected when the declared size exceeds the configured limit. + +## Connection metadata and policy + +Every socket Command invocation is associated with `SocketCommandMetadata`: + +```text +Transport +RemoteAddress +RemotePort +SessionID +RequestID +``` + +`TCPCommandServer` populates TCP connection metadata automatically. + +Applications can install a policy hook: + +```cpp +commandServer.SetPolicy( + [](const Sockets::SocketCommandInvocationContext& context) { + if (context.Metadata.RemoteAddress != "192.168.1.50") { + return Command::CommandResult::Error("Remote client not permitted", 403); + } + + return Command::CommandResult::Ok(); + } +); +``` + +This provides an authorization/rate-limiting/audit boundary without putting application semantics into ESPressio Sockets. + +A result observer can inspect completed invocations: + +```cpp +commandServer.SetResultObserver( + [](const Sockets::SocketCommandInvocationContext& context, + const Command::CommandResult& result) { + // audit / diagnostics + } +); +``` + +## Command vs Event over sockets + +Command and Event transports are complementary rather than interchangeable: + +```text +Command + remote caller asks the device to do something + +Event + a device reports that something happened +``` + +A typical architecture can therefore receive a Command over TCP and subsequently emit an Event describing the resulting state change. +''' + if '# Command Invocation over Sockets' not in s: + s += command_section + p.write_text(s) + + # dependency chart + p = Path('ESPRESSIO_DEPENDENCY_CHART.md') + s = p.read_text() + s = s.replace('ESPressio Sockets 0.2.3', 'ESPressio Sockets 0.3.0') + section = r''' + +## ESPressio Sockets → ESPressio Command — opt-in + +Sockets 0.3.0 adds optional remote Command invocation through `SocketCommandSession` and `TCPCommandServer`. + +```text +ESPressio Sockets core + -> no required ESPressio Command dependency + +Socket Command integration + - - -> ESPressio Command >= 0.2.0 < 1.0.0 +``` + +The integration provides byte-stream framing, per-client state, connection/request metadata, policy hooks, and `CommandResult` response transport. Command definition, parsing, validation, routing and callback execution remain owned by ESPressio Command. + +The structured binary protocol is implemented directly by Sockets and does not introduce a Serializable dependency. +''' + if 'ESPressio Sockets → ESPressio Command' not in s: + s += section + p.write_text(s) + PY + + - name: Add TCP Command example + run: | + mkdir -p examples/TCPCommandServer + cat > examples/TCPCommandServer/TCPCommandServer.ino <<'EOF' + #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); + } + EOF + + - name: Commit finalization + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- library.json library.properties component.mk src/ESPressio_Sockets.hpp CHANGELOG.md README.md ESPRESSIO_DEPENDENCY_CHART.md examples/TCPCommandServer/TCPCommandServer.ino + git commit -m "docs: prepare Sockets 0.3.0 Command integration" || true + git push origin HEAD:feature/command-socket-integration From d68313c3288b5f97a7b6a84bd536ff81f5474b75 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:26:17 +0200 Subject: [PATCH 16/46] chore: bump Sockets metadata to 0.3.0 --- library.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", From 70c48e0bc507b2a6e208f63636aa5e2f651fd123 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:26:28 +0200 Subject: [PATCH 17/46] chore: update Sockets 0.3.0 package description --- library.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From bf94b8292b19f93b6cbfddf3643f430f81c3b854 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:26:40 +0200 Subject: [PATCH 18/46] chore: bump Sockets component version to 0.3.0 --- component.mk | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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\" From 520719c5e7ca7bf4c7190f77e0428b8fd5af0d9f Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:26:53 +0200 Subject: [PATCH 19/46] docs: document optional Command headers --- src/ESPressio_Sockets.hpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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. */ From da71e2517205986c3003720efe699d093ca26685 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:27:22 +0200 Subject: [PATCH 20/46] docs: add Sockets 0.3.0 changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From c9da495799ca089c15bcf319714366102b2dcbba Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:27:36 +0200 Subject: [PATCH 21/46] docs: add TCP Command server example --- .../TCPCommandServer/TCPCommandServer.ino | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 examples/TCPCommandServer/TCPCommandServer.ino 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); +} From ad90baa142a556d747e3487d4688e3ed78621c05 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:29:23 +0200 Subject: [PATCH 22/46] docs: add socket Command integration guide --- COMMAND_INTEGRATION.md | 262 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 COMMAND_INTEGRATION.md 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. From d6eef5298772d6ae04a87fe707045d2bc6896d47 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:29:38 +0200 Subject: [PATCH 23/46] chore: align README with Sockets 0.3.0 --- .github/workflows/patch-readme-links.yml | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/patch-readme-links.yml diff --git a/.github/workflows/patch-readme-links.yml b/.github/workflows/patch-readme-links.yml new file mode 100644 index 0000000..c1ef912 --- /dev/null +++ b/.github/workflows/patch-readme-links.yml @@ -0,0 +1,43 @@ +name: Patch README Links + +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/patch-readme-links.yml'] + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + - run: | + python3 - <<'PY' + from pathlib import Path + p = Path('README.md') + s = p.read_text() + s = s.replace('The current repository version is **0.2.3**.', 'The current repository version is **0.3.0**.', 1) + s = s.replace('ESPressio Sockets `0.2.3` targets', 'ESPressio Sockets `0.3.0` targets', 1) + s = s.replace('flowduino/ESPressio-Sockets@^0.2.3', 'flowduino/ESPressio-Sockets@^0.3.0') + marker = 'and therefore the Serializable support used by ESPressio Event Transport.\n' + addition = '\nCommand 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.\n' + if addition.strip() not in s and marker in s: + s = s.replace(marker, marker + addition, 1) + p.write_text(s) + + p = Path('ESPRESSIO_DEPENDENCY_CHART.md') + s = p.read_text() + section = '\n\n## ESPressio Sockets → ESPressio Command — opt-in\n\nSockets 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.\n' + if 'ESPressio Sockets → ESPressio Command — opt-in' not in s: + s += section + p.write_text(s) + PY + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- README.md ESPRESSIO_DEPENDENCY_CHART.md + git commit -m "docs: align Sockets 0.3.0 Command integration" + git push origin HEAD:feature/command-socket-integration From d5ded02ecb111f7d949edb7581cb32e353a29540 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:29:45 +0000 Subject: [PATCH 24/46] docs: align Sockets 0.3.0 Command integration --- ESPRESSIO_DEPENDENCY_CHART.md | 5 +++++ README.md | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) 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 From d76699e52870ec512088880a0d340e50b8569090 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:30:45 +0200 Subject: [PATCH 25/46] test: add IPAddress host stub --- tests/stubs/IPAddress.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/stubs/IPAddress.h 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; +}; From 21ca57286954cb91f731ea4105e2d3227395f544 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:31:02 +0200 Subject: [PATCH 26/46] chore: revalidate Command feature --- .../workflows/validate-command-feature-2.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/validate-command-feature-2.yml diff --git a/.github/workflows/validate-command-feature-2.yml b/.github/workflows/validate-command-feature-2.yml new file mode 100644 index 0000000..bb17eb9 --- /dev/null +++ b/.github/workflows/validate-command-feature-2.yml @@ -0,0 +1,58 @@ +name: Validate Command Feature 2 +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/validate-command-feature-2.yml'] +permissions: + contents: write +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Command + ref: 0.2.0 + path: deps/ESPressio-Command + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Timing + ref: 2.2.2 + path: deps/ESPressio-Timing + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Units + ref: 0.2.1 + path: deps/ESPressio-Units + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + - name: Validate and report + shell: bash + run: | + set +e + { + 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" + c1=$?; echo "configure_exit=$c1" + if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi + echo "build_exit=$c2" + if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi + echo "test_exit=$c3" + } > validation2.txt 2>&1 + cat validation2.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p .ci + mv validation2.txt .ci/command-feature-validation-2.txt + git add -- .ci/command-feature-validation-2.txt + git commit -m "chore: record final Command feature validation" + git push origin HEAD:feature/command-socket-integration From c3bd4a1d2195f5e160599e844f777697c2c64ccf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:31:19 +0000 Subject: [PATCH 27/46] chore: record final Command feature validation --- .ci/command-feature-validation-2.txt | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .ci/command-feature-validation-2.txt diff --git a/.ci/command-feature-validation-2.txt b/.ci/command-feature-validation-2.txt new file mode 100644 index 0000000..2791806 --- /dev/null +++ b/.ci/command-feature-validation-2.txt @@ -0,0 +1,34 @@ +-- The CXX compiler identification is GNU 13.3.0 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/bin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (1.8s) +-- Generating done (0.0s) +-- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +configure_exit=0 +[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o +[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o +[ 75%] Linking CXX executable test_clock_sync_protocol +[ 75%] Built target test_clock_sync_protocol +[100%] Linking CXX executable test_socket_command +[100%] Built target test_socket_command +build_exit=0 +Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build + Start 1: SocketCommand +1/2 Test #1: SocketCommand ....................Subprocess aborted***Exception: 0.08 sec +test_socket_command: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/test_socket_command.cpp:175: main()::: Assertion `context.Metadata.RequestID == 42' failed. + + Start 2: ClockSynchronizationProtocol +2/2 Test #2: ClockSynchronizationProtocol ..... Passed 0.00 sec + +50% tests passed, 1 tests failed out of 2 + +Total Test time (real) = 0.09 sec + +The following tests FAILED: + 1 - SocketCommand (Subprocess aborted) +Errors while running CTest +test_exit=8 From e340e9d432fa1b34727cef6992479973a00bd152 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:32:59 +0200 Subject: [PATCH 28/46] chore: finalize and validate socket Command tests --- .../workflows/final-command-validation.yml | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/final-command-validation.yml diff --git a/.github/workflows/final-command-validation.yml b/.github/workflows/final-command-validation.yml new file mode 100644 index 0000000..6ea9a51 --- /dev/null +++ b/.github/workflows/final-command-validation.yml @@ -0,0 +1,116 @@ +name: Final Command Validation +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/final-command-validation.yml'] +permissions: + contents: write +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + + - name: Tighten Command session and tests + run: | + python3 - <<'PY' + from pathlib import Path + + # The policy used by the structured multi-request test should validate + # the metadata contract, not hard-code the first request ID forever. + p = Path('tests/test_socket_command.cpp') + s = p.read_text() + s = s.replace( + ' assert(context.Metadata.RequestID == 42);\n assert(context.Invocation.path.size() == 2);', + ' assert(context.Metadata.RequestID >= 42 && context.Metadata.RequestID <= 44);\n assert(context.Invocation.path.size() == 2);', + 1 + ) + + # Add an explicit unknown-command assertion before the first test block closes. + marker = ' assert(AsString(output) == "ERR 403 remote policy denied\\n");\n }\n\n {\n Command::CommandRegistry registry;' + replacement = ''' assert(AsString(output) == "ERR 403 remote policy denied\\n");\n\n session.SetPolicy({});\n session.SetResultObserver({});\n output.clear();\n const char* unknown = "does-not-exist\\n";\n assert(session.Feed(reinterpret_cast(unknown), std::strlen(unknown)));\n assert(AsString(output).find("ERR 1 Unknown command") == 0);\n }\n\n {\n Command::CommandRegistry registry;''' + if marker not in s: + raise SystemExit('unknown-command test insertion point missing') + s = s.replace(marker, replacement, 1) + p.write_text(s) + + # Make DisconnectOnProtocolError meaningful at the session boundary: + # protocol errors still emit an error response, but return false when + # the caller requested disconnection. + p = Path('src/ESPressio_SocketCommandSession.hpp') + s = p.read_text() + old = ''' success = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length")) && success;\n continue;''' + new = ''' const bool wrote = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length"));\n if (_config.DisconnectOnProtocolError) return false;\n success = wrote && success;\n continue;''' + if old not in s: + raise SystemExit('line protocol-error path missing') + s = s.replace(old, new, 1) + + old = ''' _structured.clear();\n _expectedStructuredBytes = 0;\n return EmitStructuredError(0, "Structured Command frame exceeds configured limit");''' + new = ''' _structured.clear();\n _expectedStructuredBytes = 0;\n const bool wrote = EmitStructuredError(0, "Structured Command frame exceeds configured limit");\n return _config.DisconnectOnProtocolError ? false : wrote;''' + if old not in s: + raise SystemExit('structured protocol-error path missing') + s = s.replace(old, new, 1) + p.write_text(s) + + # Add explicit disconnect-policy tests. + p = Path('tests/test_socket_command.cpp') + s = p.read_text() + marker = ''' {\n // Protocol rejects truncation, invalid magic/version and malformed response state.''' + block = ''' {\n Command::CommandRegistry registry;\n registry.Command("ping").OnExecute([](const Command::CommandContext&) {\n return Command::CommandResult::Ok("pong");\n });\n Sockets::SocketCommandSessionConfig config;\n config.MaximumRequestBytes = 4;\n config.DisconnectOnProtocolError = true;\n std::vector output;\n Sockets::SocketCommandSession session;\n assert(session.Initialize(registry, config, {}, [&](const uint8_t* d, std::size_t n) {\n output.insert(output.end(), d, d + n);\n return true;\n }));\n const char* oversized = "12345\\n";\n assert(!session.Feed(reinterpret_cast(oversized), std::strlen(oversized)));\n assert(AsString(output) == "ERR 1 Command exceeds maximum request length\\n");\n }\n\n''' + if marker not in s: + raise SystemExit('disconnect test insertion point missing') + s = s.replace(marker, block + marker, 1) + p.write_text(s) + PY + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- src/ESPressio_SocketCommandSession.hpp tests/test_socket_command.cpp + git commit -m "test: tighten socket Command protocol coverage" + git push origin HEAD:feature/command-socket-integration + + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Command + ref: 0.2.0 + path: deps/ESPressio-Command + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Timing + ref: 2.2.2 + path: deps/ESPressio-Timing + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Units + ref: 0.2.1 + path: deps/ESPressio-Units + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + + - name: Validate and report + shell: bash + run: | + set +e + { + 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" + c1=$?; echo "configure_exit=$c1" + if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi + echo "build_exit=$c2" + if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi + echo "test_exit=$c3" + } > final-validation.txt 2>&1 + cat final-validation.txt + mkdir -p .ci + mv final-validation.txt .ci/final-command-validation.txt + git add -- .ci/final-command-validation.txt + git commit -m "chore: record final socket Command validation" + git push origin HEAD:feature/command-socket-integration From b1573760bb9b6f8c9895ffa19bcd0f6095f8529b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:33:07 +0000 Subject: [PATCH 29/46] test: tighten socket Command protocol coverage --- src/ESPressio_SocketCommandSession.hpp | 7 +++++-- tests/test_socket_command.cpp | 28 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ESPressio_SocketCommandSession.hpp b/src/ESPressio_SocketCommandSession.hpp index 71a5eb8..735e5bf 100644 --- a/src/ESPressio_SocketCommandSession.hpp +++ b/src/ESPressio_SocketCommandSession.hpp @@ -150,7 +150,9 @@ class SocketCommandSession final { if (_discardUntilNewline) { _discardUntilNewline = false; _line.clear(); - success = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length")) && success; + 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); @@ -216,7 +218,8 @@ class SocketCommandSession final { if (_expectedStructuredBytes == 0 || _expectedStructuredBytes > _config.MaximumRequestBytes) { _structured.clear(); _expectedStructuredBytes = 0; - return EmitStructuredError(0, "Structured Command frame exceeds configured limit"); + const bool wrote = EmitStructuredError(0, "Structured Command frame exceeds configured limit"); + return _config.DisconnectOnProtocolError ? false : wrote; } } if (_structured.size() < _expectedStructuredBytes) break; diff --git a/tests/test_socket_command.cpp b/tests/test_socket_command.cpp index b82f301..7839c07 100644 --- a/tests/test_socket_command.cpp +++ b/tests/test_socket_command.cpp @@ -99,6 +99,13 @@ int main() { 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); } { @@ -172,7 +179,7 @@ int main() { policyCalled = true; assert(context.Metadata.Transport == "tcp"); assert(context.Metadata.SessionID == 99); - assert(context.Metadata.RequestID == 42); + assert(context.Metadata.RequestID >= 42 && context.Metadata.RequestID <= 44); assert(context.Invocation.path.size() == 2); return Command::CommandResult::Ok(); }); @@ -241,6 +248,25 @@ int main() { 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; From 7e521dfb08ef53ca77376a881313bff72088d3ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:33:14 +0000 Subject: [PATCH 30/46] chore: record final socket Command validation --- .ci/final-command-validation.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .ci/final-command-validation.txt diff --git a/.ci/final-command-validation.txt b/.ci/final-command-validation.txt new file mode 100644 index 0000000..c05d343 --- /dev/null +++ b/.ci/final-command-validation.txt @@ -0,0 +1,28 @@ +-- The CXX compiler identification is GNU 13.3.0 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/bin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.7s) +-- Generating done (0.0s) +-- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +configure_exit=0 +[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o +[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o +[ 75%] Linking CXX executable test_clock_sync_protocol +[ 75%] Built target test_clock_sync_protocol +[100%] Linking CXX executable test_socket_command +[100%] Built target test_socket_command +build_exit=0 +Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build + Start 1: SocketCommand +1/2 Test #1: SocketCommand .................... Passed 0.00 sec + Start 2: ClockSynchronizationProtocol +2/2 Test #2: ClockSynchronizationProtocol ..... Passed 0.00 sec + +100% tests passed, 0 tests failed out of 2 + +Total Test time (real) = 0.00 sec +test_exit=0 From 9634c5221c9b8af366584371100a893fd32026c5 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:33:42 +0200 Subject: [PATCH 31/46] test: add FreeRTOS host stub --- tests/stubs/freertos/FreeRTOS.h | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/stubs/freertos/FreeRTOS.h 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 From 8d95b726bdf457c0736a2ac9ded300db493ee364 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:33:52 +0200 Subject: [PATCH 32/46] test: add FreeRTOS task host stub --- tests/stubs/freertos/task.h | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/stubs/freertos/task.h 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" From 8b3ce40ca49b04e7f3848c0ed545ea9b928034ed Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:34:02 +0200 Subject: [PATCH 33/46] test: verify core Sockets remains Command-independent --- tests/test_core_include.cpp | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/test_core_include.cpp 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; +} From 4cc67db5f9e85e1d70c21649ef58407f2b16a12b Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:34:28 +0200 Subject: [PATCH 34/46] test: add Command-independent core regression --- tests/CMakeLists.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 747db45..79d930d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,8 +3,14 @@ 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") + message(FATAL_ERROR "ESPRESSIO_COMMAND_INCLUDE_DIR is required for SocketCommand tests") endif() add_executable(test_socket_command test_socket_command.cpp) From 325e4d4cb8cac98f899b17b2aa1097a1022e049c Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:34:42 +0200 Subject: [PATCH 35/46] chore: validate final Sockets 0.3.0 host suite --- .github/workflows/validate-final-suite.yml | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/validate-final-suite.yml diff --git a/.github/workflows/validate-final-suite.yml b/.github/workflows/validate-final-suite.yml new file mode 100644 index 0000000..ac95cd2 --- /dev/null +++ b/.github/workflows/validate-final-suite.yml @@ -0,0 +1,58 @@ +name: Validate Final Suite +on: + push: + branches: [feature/command-socket-integration] + paths: ['.github/workflows/validate-final-suite.yml'] +permissions: + contents: write +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feature/command-socket-integration + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Command + ref: 0.2.0 + path: deps/ESPressio-Command + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Timing + ref: 2.2.2 + path: deps/ESPressio-Timing + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Units + ref: 0.2.1 + path: deps/ESPressio-Units + - uses: actions/checkout@v4 + with: + repository: Flowduino/ESPressio-Observable + ref: 3.0.1 + path: deps/ESPressio-Observable + - name: Validate and report + shell: bash + run: | + set +e + { + 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" + c1=$?; echo "configure_exit=$c1" + if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi + echo "build_exit=$c2" + if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi + echo "test_exit=$c3" + } > validation-final-suite.txt 2>&1 + cat validation-final-suite.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p .ci + mv validation-final-suite.txt .ci/validation-final-suite.txt + git add -- .ci/validation-final-suite.txt + git commit -m "chore: record final Sockets 0.3.0 host validation" + git push origin HEAD:feature/command-socket-integration From dc24484283861afbcd554a3116e07a5b444ebc8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:34:56 +0000 Subject: [PATCH 36/46] chore: record final Sockets 0.3.0 host validation --- .ci/validation-final-suite.txt | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .ci/validation-final-suite.txt diff --git a/.ci/validation-final-suite.txt b/.ci/validation-final-suite.txt new file mode 100644 index 0000000..fbcd5df --- /dev/null +++ b/.ci/validation-final-suite.txt @@ -0,0 +1,33 @@ +-- The CXX compiler identification is GNU 13.3.0 +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/bin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Configuring done (0.3s) +-- Generating done (0.0s) +-- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +configure_exit=0 +[ 33%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o +[ 33%] Building CXX object CMakeFiles/test_core_include.dir/test_core_include.cpp.o +[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o +[ 66%] Linking CXX executable test_core_include +[ 66%] Built target test_core_include +[ 83%] Linking CXX executable test_clock_sync_protocol +[ 83%] Built target test_clock_sync_protocol +[100%] Linking CXX executable test_socket_command +[100%] Built target test_socket_command +build_exit=0 +Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build +Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build + Start 1: CoreWithoutCommand +1/3 Test #1: CoreWithoutCommand ............... Passed 0.00 sec + Start 2: SocketCommand +2/3 Test #2: SocketCommand .................... Passed 0.00 sec + Start 3: ClockSynchronizationProtocol +3/3 Test #3: ClockSynchronizationProtocol ..... Passed 0.00 sec + +100% tests passed, 0 tests failed out of 3 + +Total Test time (real) = 0.01 sec +test_exit=0 From b6a3736f8f63fe61e002534500b3d28ac89238a8 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:35:38 +0200 Subject: [PATCH 37/46] chore: remove temporary validation workflow --- .../workflows/final-command-validation.yml | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 .github/workflows/final-command-validation.yml diff --git a/.github/workflows/final-command-validation.yml b/.github/workflows/final-command-validation.yml deleted file mode 100644 index 6ea9a51..0000000 --- a/.github/workflows/final-command-validation.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Final Command Validation -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/final-command-validation.yml'] -permissions: - contents: write -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - - name: Tighten Command session and tests - run: | - python3 - <<'PY' - from pathlib import Path - - # The policy used by the structured multi-request test should validate - # the metadata contract, not hard-code the first request ID forever. - p = Path('tests/test_socket_command.cpp') - s = p.read_text() - s = s.replace( - ' assert(context.Metadata.RequestID == 42);\n assert(context.Invocation.path.size() == 2);', - ' assert(context.Metadata.RequestID >= 42 && context.Metadata.RequestID <= 44);\n assert(context.Invocation.path.size() == 2);', - 1 - ) - - # Add an explicit unknown-command assertion before the first test block closes. - marker = ' assert(AsString(output) == "ERR 403 remote policy denied\\n");\n }\n\n {\n Command::CommandRegistry registry;' - replacement = ''' assert(AsString(output) == "ERR 403 remote policy denied\\n");\n\n session.SetPolicy({});\n session.SetResultObserver({});\n output.clear();\n const char* unknown = "does-not-exist\\n";\n assert(session.Feed(reinterpret_cast(unknown), std::strlen(unknown)));\n assert(AsString(output).find("ERR 1 Unknown command") == 0);\n }\n\n {\n Command::CommandRegistry registry;''' - if marker not in s: - raise SystemExit('unknown-command test insertion point missing') - s = s.replace(marker, replacement, 1) - p.write_text(s) - - # Make DisconnectOnProtocolError meaningful at the session boundary: - # protocol errors still emit an error response, but return false when - # the caller requested disconnection. - p = Path('src/ESPressio_SocketCommandSession.hpp') - s = p.read_text() - old = ''' success = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length")) && success;\n continue;''' - new = ''' const bool wrote = WriteLineResult(Command::CommandResult::Error("Command exceeds maximum request length"));\n if (_config.DisconnectOnProtocolError) return false;\n success = wrote && success;\n continue;''' - if old not in s: - raise SystemExit('line protocol-error path missing') - s = s.replace(old, new, 1) - - old = ''' _structured.clear();\n _expectedStructuredBytes = 0;\n return EmitStructuredError(0, "Structured Command frame exceeds configured limit");''' - new = ''' _structured.clear();\n _expectedStructuredBytes = 0;\n const bool wrote = EmitStructuredError(0, "Structured Command frame exceeds configured limit");\n return _config.DisconnectOnProtocolError ? false : wrote;''' - if old not in s: - raise SystemExit('structured protocol-error path missing') - s = s.replace(old, new, 1) - p.write_text(s) - - # Add explicit disconnect-policy tests. - p = Path('tests/test_socket_command.cpp') - s = p.read_text() - marker = ''' {\n // Protocol rejects truncation, invalid magic/version and malformed response state.''' - block = ''' {\n Command::CommandRegistry registry;\n registry.Command("ping").OnExecute([](const Command::CommandContext&) {\n return Command::CommandResult::Ok("pong");\n });\n Sockets::SocketCommandSessionConfig config;\n config.MaximumRequestBytes = 4;\n config.DisconnectOnProtocolError = true;\n std::vector output;\n Sockets::SocketCommandSession session;\n assert(session.Initialize(registry, config, {}, [&](const uint8_t* d, std::size_t n) {\n output.insert(output.end(), d, d + n);\n return true;\n }));\n const char* oversized = "12345\\n";\n assert(!session.Feed(reinterpret_cast(oversized), std::strlen(oversized)));\n assert(AsString(output) == "ERR 1 Command exceeds maximum request length\\n");\n }\n\n''' - if marker not in s: - raise SystemExit('disconnect test insertion point missing') - s = s.replace(marker, block + marker, 1) - p.write_text(s) - PY - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- src/ESPressio_SocketCommandSession.hpp tests/test_socket_command.cpp - git commit -m "test: tighten socket Command protocol coverage" - git push origin HEAD:feature/command-socket-integration - - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Command - ref: 0.2.0 - path: deps/ESPressio-Command - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Timing - ref: 2.2.2 - path: deps/ESPressio-Timing - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Units - ref: 0.2.1 - path: deps/ESPressio-Units - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Observable - ref: 3.0.1 - path: deps/ESPressio-Observable - - - name: Validate and report - shell: bash - run: | - set +e - { - 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" - c1=$?; echo "configure_exit=$c1" - if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi - echo "build_exit=$c2" - if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi - echo "test_exit=$c3" - } > final-validation.txt 2>&1 - cat final-validation.txt - mkdir -p .ci - mv final-validation.txt .ci/final-command-validation.txt - git add -- .ci/final-command-validation.txt - git commit -m "chore: record final socket Command validation" - git push origin HEAD:feature/command-socket-integration From 933b368bcc2df3678d47f438f8a35c54ea20f6ad Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:35:48 +0200 Subject: [PATCH 38/46] chore: remove temporary finalization workflow --- .../workflows/finalize-command-feature.yml | 403 ------------------ 1 file changed, 403 deletions(-) delete mode 100644 .github/workflows/finalize-command-feature.yml diff --git a/.github/workflows/finalize-command-feature.yml b/.github/workflows/finalize-command-feature.yml deleted file mode 100644 index 9d62c1b..0000000 --- a/.github/workflows/finalize-command-feature.yml +++ /dev/null @@ -1,403 +0,0 @@ -name: Finalize Command Feature - -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/finalize-command-feature.yml'] - -permissions: - contents: write - -jobs: - finalize: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - - name: Update release metadata and documentation - run: | - python3 - <<'PY' - from pathlib import Path - import json - - # library.json - p = Path('library.json') - data = json.loads(p.read_text()) - data['version'] = '0.3.0' - data['description'] = 'Socket-based ESPressio transports, Command invocation adapters, and Timing synchronization providers for ESP32.' - data['keywords'] = 'esp32,sockets,udp,tcp,tls,websocket,mqtt,event,command,cli,transport,network,timing,clock,synchronization,sntp,espressio' - p.write_text(json.dumps(data, indent=4) + '\n') - - # library.properties - p = Path('library.properties') - s = p.read_text() - s = s.replace('version=0.2.3', 'version=0.3.0') - s = s.replace('sentence=Socket-based Event Transport and System Clock synchronization implementations for the ESPressio ecosystem.', - 'sentence=Socket-based Event Transport, Command invocation, and System Clock synchronization for the ESPressio ecosystem.') - s = s.replace('paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports plus opt-in ESPressio Timing System Clock synchronization over UDP, TCP, WebSocket and SNTP.', - 'paragraph=Provides UDP, TCP, TLS, WebSocket and MQTT Event transports, opt-in TCP ESPressio Command invocation, and opt-in ESPressio Timing System Clock synchronization.') - p.write_text(s) - - # component.mk - p = Path('component.mk') - s = p.read_text() - s = s.replace('ESPRESSIO_SOCKETS_VERSION_MINOR=2', 'ESPRESSIO_SOCKETS_VERSION_MINOR=3') - s = s.replace('ESPRESSIO_SOCKETS_VERSION_PATCH=3', 'ESPRESSIO_SOCKETS_VERSION_PATCH=0') - s = s.replace('ESPRESSIO_SOCKETS_VERSION_STRING=\\"0.2.3\\"', 'ESPRESSIO_SOCKETS_VERSION_STRING=\\"0.3.0\\"') - p.write_text(s) - - # umbrella guidance (do not batch-include optional Command dependency) - p = Path('src/ESPressio_Sockets.hpp') - s = p.read_text() - marker = ''' * Timing synchronization is likewise opt-in through:\n *\n * ESPressio_SocketClockSynchronization.hpp\n *\n * This keeps ESPressio Event/Serializable and ESPressio Timing dependencies\n * opt-in at the consuming-code level.\n''' - replacement = ''' * Timing synchronization is likewise opt-in through:\n *\n * ESPressio_SocketClockSynchronization.hpp\n *\n * Command invocation is opt-in through:\n *\n * ESPressio_SocketCommandSession.hpp\n * ESPressio_TCPCommandServer.hpp\n *\n * This keeps ESPressio Event/Serializable, ESPressio Timing, and ESPressio\n * Command dependencies opt-in at the consuming-code level.\n''' - if marker in s: - s = s.replace(marker, replacement, 1) - p.write_text(s) - - # changelog - p = Path('CHANGELOG.md') - s = p.read_text() - entry = '''## 0.3.0 — 2026-08-20\n\n### Added\n- Added opt-in ESPressio Command 0.2.x integration for remote Command invocation over sockets.\n- Added host-testable `SocketCommandSession` with line-oriented and structured-binary request modes.\n- Added `TCPCommandServer` with isolated per-client Command sessions.\n- Added request/connection metadata, policy hooks, result observers, correlation IDs, bounded request handling, and structured request/response framing.\n- Added a TCP Command server example and comprehensive host tests for Command framing, dispatch, validation, session isolation, policy and error paths.\n- Added a permanent GitHub Actions host-test workflow pinned to released ESPressio dependencies.\n\n### Changed\n- Updated package/component version metadata to 0.3.0.\n- Updated README and ESPressio dependency documentation for optional Command integration.\n- Expanded host regression testing to retain coverage of the existing socket clock-synchronization protocol.\n\n### Compatibility\n- Core ESPressio Sockets remains independent of ESPressio Command.\n- Existing Event Transport and Timing synchronization APIs remain source-compatible.\n- ESPressio Command is required only when Command integration headers are selected.\n\n''' - if '## 0.3.0' not in s: - s = s.replace('# Changelog\n\n', '# Changelog\n\n' + entry, 1) - p.write_text(s) - - # README current version and Command section - p = Path('README.md') - s = p.read_text() - s = s.replace('The current repository version is **0.2.3**.', 'The current repository version is **0.3.0**.', 1) - s = s.replace('ESPressio Sockets `0.2.3` targets', 'ESPressio Sockets `0.3.0` targets', 1) - command_section = r''' - -# Command Invocation over Sockets - -Version 0.3.0 adds opt-in integration with **ESPressio Command >= 0.2.0 < 1.0.0**. - -Core Sockets does **not** require ESPressio Command. Include Command integration only where remote Command invocation is required: - -```cpp -#include -``` - -PlatformIO: - -```ini -lib_deps = - flowduino/ESPressio-Sockets@^0.3.0 - flowduino/ESPressio-Command@^0.2.0 -``` - -The dependency direction remains: - -```text -ESPressio Sockets core - -> no Command dependency - -Socket Command integration - - - -> ESPressio Command >= 0.2.0 < 1.0.0 -``` - -## Command 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 -``` - -`SocketCommandSession` is transport-neutral at the byte-stream boundary and is host-testable independently of Wi-Fi. `TCPCommandServer` binds an independent session to each accepted `WiFiClient`. - -## Line-oriented mode - -Line mode is suitable for interactive clients such as `nc`, telnet-style terminals, or simple custom controllers: - -```text -gpio write 2 high -system status -``` - -Each newline-delimited request is resolved by ESPressio Command and produces one response: - -```text -OK 0 System OK -ERR 1 Unknown command 'example' -``` - -Both LF and CRLF input are accepted. Quoting and escaping are delegated to ESPressio Command's text parser. - -Example configuration: - -```cpp -#include -#include - -using namespace ESPressio; - -Sockets::TCPCommandServer commandServer; - -void setup() { - auto& registry = Command::CommandRegistry::GetInstance(); - - registry.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; - - commandServer.Initialize(config, registry); -} -``` - -The consuming application remains responsible for establishing Wi-Fi/network connectivity before initializing the server. - -## Structured-binary mode - -Machine-to-machine clients can avoid manufacturing command-line text by sending a structured `CommandInvocation` representation. - -The version-1 request carries: - -```text -request/correlation ID -Command path -positional parameters -named parameters -raw caller metadata string -``` - -The response carries: - -```text -matching request/correlation ID -success/failure -CommandResult code -CommandResult message -``` - -Structured payloads use an explicit 32-bit length prefix and an ESPressio-owned binary format. This mode intentionally does **not** require ESPressio Serializable or JSON. - -Helpers are exposed through: - -```cpp -#include -``` - -including: - -```cpp -SocketCommandProtocol::EncodeRequest(...) -SocketCommandProtocol::DecodeRequest(...) -SocketCommandProtocol::EncodeResponse(...) -SocketCommandProtocol::DecodeResponse(...) -SocketCommandProtocol::FrameStructuredPayload(...) -``` - -## Per-client session isolation - -`TCPCommandServer` allocates an independent `SocketCommandSession` for every connected client. Partial lines, structured frame state, request IDs, and protocol errors therefore cannot leak between clients. - -The maximum number of clients remains bounded by both `MaximumClients` and `ESPRESSIO_SOCKETS_MAX_TCP_CLIENTS`. - -## Request limits and protocol errors - -`SocketCommandSessionConfig` provides: - -```text -Mode -MaximumRequestBytes -DisconnectOnProtocolError -IgnoreEmptyLines -``` - -Input accumulation is bounded by `MaximumRequestBytes`. Oversized line requests are discarded through the next newline and the session then recovers for subsequent commands. Structured frames declare their length before payload processing and are rejected when the declared size exceeds the configured limit. - -## Connection metadata and policy - -Every socket Command invocation is associated with `SocketCommandMetadata`: - -```text -Transport -RemoteAddress -RemotePort -SessionID -RequestID -``` - -`TCPCommandServer` populates TCP connection metadata automatically. - -Applications can install a policy hook: - -```cpp -commandServer.SetPolicy( - [](const Sockets::SocketCommandInvocationContext& context) { - if (context.Metadata.RemoteAddress != "192.168.1.50") { - return Command::CommandResult::Error("Remote client not permitted", 403); - } - - return Command::CommandResult::Ok(); - } -); -``` - -This provides an authorization/rate-limiting/audit boundary without putting application semantics into ESPressio Sockets. - -A result observer can inspect completed invocations: - -```cpp -commandServer.SetResultObserver( - [](const Sockets::SocketCommandInvocationContext& context, - const Command::CommandResult& result) { - // audit / diagnostics - } -); -``` - -## Command vs Event over sockets - -Command and Event transports are complementary rather than interchangeable: - -```text -Command - remote caller asks the device to do something - -Event - a device reports that something happened -``` - -A typical architecture can therefore receive a Command over TCP and subsequently emit an Event describing the resulting state change. -''' - if '# Command Invocation over Sockets' not in s: - s += command_section - p.write_text(s) - - # dependency chart - p = Path('ESPRESSIO_DEPENDENCY_CHART.md') - s = p.read_text() - s = s.replace('ESPressio Sockets 0.2.3', 'ESPressio Sockets 0.3.0') - section = r''' - -## ESPressio Sockets → ESPressio Command — opt-in - -Sockets 0.3.0 adds optional remote Command invocation through `SocketCommandSession` and `TCPCommandServer`. - -```text -ESPressio Sockets core - -> no required ESPressio Command dependency - -Socket Command integration - - - -> ESPressio Command >= 0.2.0 < 1.0.0 -``` - -The integration provides byte-stream framing, per-client state, connection/request metadata, policy hooks, and `CommandResult` response transport. Command definition, parsing, validation, routing and callback execution remain owned by ESPressio Command. - -The structured binary protocol is implemented directly by Sockets and does not introduce a Serializable dependency. -''' - if 'ESPressio Sockets → ESPressio Command' not in s: - s += section - p.write_text(s) - PY - - - name: Add TCP Command example - run: | - mkdir -p examples/TCPCommandServer - cat > examples/TCPCommandServer/TCPCommandServer.ino <<'EOF' - #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); - } - EOF - - - name: Commit finalization - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- library.json library.properties component.mk src/ESPressio_Sockets.hpp CHANGELOG.md README.md ESPRESSIO_DEPENDENCY_CHART.md examples/TCPCommandServer/TCPCommandServer.ino - git commit -m "docs: prepare Sockets 0.3.0 Command integration" || true - git push origin HEAD:feature/command-socket-integration From 5a270fc80cb0dc362cd67b552147e783bf88ad25 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:01 +0200 Subject: [PATCH 39/46] chore: remove temporary README patch workflow --- .github/workflows/patch-readme-links.yml | 43 ------------------------ 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/patch-readme-links.yml diff --git a/.github/workflows/patch-readme-links.yml b/.github/workflows/patch-readme-links.yml deleted file mode 100644 index c1ef912..0000000 --- a/.github/workflows/patch-readme-links.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Patch README Links - -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/patch-readme-links.yml'] - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - run: | - python3 - <<'PY' - from pathlib import Path - p = Path('README.md') - s = p.read_text() - s = s.replace('The current repository version is **0.2.3**.', 'The current repository version is **0.3.0**.', 1) - s = s.replace('ESPressio Sockets `0.2.3` targets', 'ESPressio Sockets `0.3.0` targets', 1) - s = s.replace('flowduino/ESPressio-Sockets@^0.2.3', 'flowduino/ESPressio-Sockets@^0.3.0') - marker = 'and therefore the Serializable support used by ESPressio Event Transport.\n' - addition = '\nCommand 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.\n' - if addition.strip() not in s and marker in s: - s = s.replace(marker, marker + addition, 1) - p.write_text(s) - - p = Path('ESPRESSIO_DEPENDENCY_CHART.md') - s = p.read_text() - section = '\n\n## ESPressio Sockets → ESPressio Command — opt-in\n\nSockets 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.\n' - if 'ESPressio Sockets → ESPressio Command — opt-in' not in s: - s += section - p.write_text(s) - PY - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- README.md ESPRESSIO_DEPENDENCY_CHART.md - git commit -m "docs: align Sockets 0.3.0 Command integration" - git push origin HEAD:feature/command-socket-integration From 5585e1ba4d4330871bfb3571a6395e52feb52385 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:15 +0200 Subject: [PATCH 40/46] chore: remove temporary validation workflow --- .../workflows/validate-command-feature-2.yml | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/validate-command-feature-2.yml diff --git a/.github/workflows/validate-command-feature-2.yml b/.github/workflows/validate-command-feature-2.yml deleted file mode 100644 index bb17eb9..0000000 --- a/.github/workflows/validate-command-feature-2.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Validate Command Feature 2 -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/validate-command-feature-2.yml'] -permissions: - contents: write -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Command - ref: 0.2.0 - path: deps/ESPressio-Command - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Timing - ref: 2.2.2 - path: deps/ESPressio-Timing - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Units - ref: 0.2.1 - path: deps/ESPressio-Units - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Observable - ref: 3.0.1 - path: deps/ESPressio-Observable - - name: Validate and report - shell: bash - run: | - set +e - { - 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" - c1=$?; echo "configure_exit=$c1" - if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi - echo "build_exit=$c2" - if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi - echo "test_exit=$c3" - } > validation2.txt 2>&1 - cat validation2.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - mkdir -p .ci - mv validation2.txt .ci/command-feature-validation-2.txt - git add -- .ci/command-feature-validation-2.txt - git commit -m "chore: record final Command feature validation" - git push origin HEAD:feature/command-socket-integration From 1f631ef756cb75a59eca9d135a4051e07c810247 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:27 +0200 Subject: [PATCH 41/46] chore: remove temporary validation workflow --- .../workflows/validate-command-feature.yml | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 .github/workflows/validate-command-feature.yml diff --git a/.github/workflows/validate-command-feature.yml b/.github/workflows/validate-command-feature.yml deleted file mode 100644 index 1621d43..0000000 --- a/.github/workflows/validate-command-feature.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Validate Command Feature - -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/validate-command-feature.yml'] - -permissions: - contents: write - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - - name: Apply policy-path correction - run: | - python3 - <<'PY' - from pathlib import Path - p = Path('src/ESPressio_SocketCommandSession.hpp') - s = p.read_text() - old = ''' // Preserve the full text path/arguments behavior by letting Command parse the raw line.\n auto result = _registry->Invoke(line);\n if (_observer) _observer(context, result);\n return WriteLineResult(result);''' - new = ''' // Text Commands are still parsed/executed by ESPressio Command, while\n // Sockets exposes transport metadata to the network policy layer.\n context.Invocation.path = {tokens.front()};\n if (_policy) {\n auto policyResult = _policy(context);\n if (!policyResult.success) {\n if (_observer) _observer(context, policyResult);\n return WriteLineResult(policyResult);\n }\n }\n auto result = _registry->Invoke(line);\n if (_observer) _observer(context, result);\n return WriteLineResult(result);''' - if old not in s: - raise SystemExit('policy patch target not found') - p.write_text(s.replace(old, new, 1)) - PY - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- src/ESPressio_SocketCommandSession.hpp - git commit -m "fix: apply socket policy to line Commands" || true - git push origin HEAD:feature/command-socket-integration - - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Command - ref: 0.2.0 - path: deps/ESPressio-Command - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Timing - ref: 2.2.2 - path: deps/ESPressio-Timing - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Units - ref: 0.2.1 - path: deps/ESPressio-Units - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Observable - ref: 3.0.1 - path: deps/ESPressio-Observable - - - name: Validate and report - shell: bash - run: | - set +e - { - echo '=== CONFIGURE ===' - 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" - c1=$? - echo "configure_exit=$c1" - if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi - echo "build_exit=$c2" - if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi - echo "test_exit=$c3" - } > validation.txt 2>&1 - cat validation.txt - mkdir -p .ci - mv validation.txt .ci/command-feature-validation.txt - git add -- .ci/command-feature-validation.txt - git commit -m "chore: record Command feature validation" || true - git push origin HEAD:feature/command-socket-integration From 719202a848832253c31906f3cb6e0346fb2526df Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:36 +0200 Subject: [PATCH 42/46] chore: remove temporary final-suite workflow --- .github/workflows/validate-final-suite.yml | 58 ---------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/validate-final-suite.yml diff --git a/.github/workflows/validate-final-suite.yml b/.github/workflows/validate-final-suite.yml deleted file mode 100644 index ac95cd2..0000000 --- a/.github/workflows/validate-final-suite.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Validate Final Suite -on: - push: - branches: [feature/command-socket-integration] - paths: ['.github/workflows/validate-final-suite.yml'] -permissions: - contents: write -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/command-socket-integration - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Command - ref: 0.2.0 - path: deps/ESPressio-Command - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Timing - ref: 2.2.2 - path: deps/ESPressio-Timing - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Units - ref: 0.2.1 - path: deps/ESPressio-Units - - uses: actions/checkout@v4 - with: - repository: Flowduino/ESPressio-Observable - ref: 3.0.1 - path: deps/ESPressio-Observable - - name: Validate and report - shell: bash - run: | - set +e - { - 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" - c1=$?; echo "configure_exit=$c1" - if [ $c1 -eq 0 ]; then cmake --build build --parallel; c2=$?; else c2=99; fi - echo "build_exit=$c2" - if [ $c1 -eq 0 ] && [ $c2 -eq 0 ]; then ctest --test-dir build --output-on-failure; c3=$?; else c3=99; fi - echo "test_exit=$c3" - } > validation-final-suite.txt 2>&1 - cat validation-final-suite.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - mkdir -p .ci - mv validation-final-suite.txt .ci/validation-final-suite.txt - git add -- .ci/validation-final-suite.txt - git commit -m "chore: record final Sockets 0.3.0 host validation" - git push origin HEAD:feature/command-socket-integration From f07893b975b2f7e290ef007ea78f1a2703080ce4 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:46 +0200 Subject: [PATCH 43/46] chore: remove temporary validation report --- .ci/command-feature-validation.txt | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .ci/command-feature-validation.txt diff --git a/.ci/command-feature-validation.txt b/.ci/command-feature-validation.txt deleted file mode 100644 index d901b3f..0000000 --- a/.ci/command-feature-validation.txt +++ /dev/null @@ -1,27 +0,0 @@ -=== CONFIGURE === --- The CXX compiler identification is GNU 13.3.0 --- Detecting CXX compiler ABI info --- Detecting CXX compiler ABI info - done --- Check for working CXX compiler: /usr/bin/c++ - skipped --- Detecting CXX compile features --- Detecting CXX compile features - done --- Configuring done (1.9s) --- Generating done (0.0s) --- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -configure_exit=0 -[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o -[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o -In file included from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationProtocol.hpp:12, - from /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/test_clock_sync_protocol.cpp:4: -/home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/../src/timing/ESPressio_SocketClockSynchronizationTypes.hpp:4:10: fatal error: IPAddress.h: No such file or directory - 4 | #include - | ^~~~~~~~~~~~~ -compilation terminated. -gmake[2]: *** [CMakeFiles/test_clock_sync_protocol.dir/build.make:79: CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o] Error 1 -gmake[1]: *** [CMakeFiles/Makefile2:122: CMakeFiles/test_clock_sync_protocol.dir/all] Error 2 -gmake[1]: *** Waiting for unfinished jobs.... -[ 75%] Linking CXX executable test_socket_command -[ 75%] Built target test_socket_command -gmake: *** [Makefile:101: all] Error 2 -build_exit=2 -test_exit=99 From 6ac7fc3323b44e5895b6c21f30004f75b5fae8ca Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:36:56 +0200 Subject: [PATCH 44/46] chore: remove temporary validation report --- .ci/command-feature-validation-2.txt | 34 ---------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .ci/command-feature-validation-2.txt diff --git a/.ci/command-feature-validation-2.txt b/.ci/command-feature-validation-2.txt deleted file mode 100644 index 2791806..0000000 --- a/.ci/command-feature-validation-2.txt +++ /dev/null @@ -1,34 +0,0 @@ --- The CXX compiler identification is GNU 13.3.0 --- Detecting CXX compiler ABI info --- Detecting CXX compiler ABI info - done --- Check for working CXX compiler: /usr/bin/c++ - skipped --- Detecting CXX compile features --- Detecting CXX compile features - done --- Configuring done (1.8s) --- Generating done (0.0s) --- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -configure_exit=0 -[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o -[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o -[ 75%] Linking CXX executable test_clock_sync_protocol -[ 75%] Built target test_clock_sync_protocol -[100%] Linking CXX executable test_socket_command -[100%] Built target test_socket_command -build_exit=0 -Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build - Start 1: SocketCommand -1/2 Test #1: SocketCommand ....................Subprocess aborted***Exception: 0.08 sec -test_socket_command: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/tests/test_socket_command.cpp:175: main()::: Assertion `context.Metadata.RequestID == 42' failed. - - Start 2: ClockSynchronizationProtocol -2/2 Test #2: ClockSynchronizationProtocol ..... Passed 0.00 sec - -50% tests passed, 1 tests failed out of 2 - -Total Test time (real) = 0.09 sec - -The following tests FAILED: - 1 - SocketCommand (Subprocess aborted) -Errors while running CTest -test_exit=8 From 8f941a615273b119ace078e82b9f5ff3674e8004 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:37:06 +0200 Subject: [PATCH 45/46] chore: remove temporary validation report --- .ci/final-command-validation.txt | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .ci/final-command-validation.txt diff --git a/.ci/final-command-validation.txt b/.ci/final-command-validation.txt deleted file mode 100644 index c05d343..0000000 --- a/.ci/final-command-validation.txt +++ /dev/null @@ -1,28 +0,0 @@ --- The CXX compiler identification is GNU 13.3.0 --- Detecting CXX compiler ABI info --- Detecting CXX compiler ABI info - done --- Check for working CXX compiler: /usr/bin/c++ - skipped --- Detecting CXX compile features --- Detecting CXX compile features - done --- Configuring done (0.7s) --- Generating done (0.0s) --- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -configure_exit=0 -[ 25%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o -[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o -[ 75%] Linking CXX executable test_clock_sync_protocol -[ 75%] Built target test_clock_sync_protocol -[100%] Linking CXX executable test_socket_command -[100%] Built target test_socket_command -build_exit=0 -Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build - Start 1: SocketCommand -1/2 Test #1: SocketCommand .................... Passed 0.00 sec - Start 2: ClockSynchronizationProtocol -2/2 Test #2: ClockSynchronizationProtocol ..... Passed 0.00 sec - -100% tests passed, 0 tests failed out of 2 - -Total Test time (real) = 0.00 sec -test_exit=0 From 109a7b9bac5496d1e83c7a5cba6c3f4f6af8c685 Mon Sep 17 00:00:00 2001 From: Simon J Stuart Date: Thu, 20 Aug 2026 13:37:19 +0200 Subject: [PATCH 46/46] chore: remove temporary validation report --- .ci/validation-final-suite.txt | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .ci/validation-final-suite.txt diff --git a/.ci/validation-final-suite.txt b/.ci/validation-final-suite.txt deleted file mode 100644 index fbcd5df..0000000 --- a/.ci/validation-final-suite.txt +++ /dev/null @@ -1,33 +0,0 @@ --- The CXX compiler identification is GNU 13.3.0 --- Detecting CXX compiler ABI info --- Detecting CXX compiler ABI info - done --- Check for working CXX compiler: /usr/bin/c++ - skipped --- Detecting CXX compile features --- Detecting CXX compile features - done --- Configuring done (0.3s) --- Generating done (0.0s) --- Build files have been written to: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -configure_exit=0 -[ 33%] Building CXX object CMakeFiles/test_socket_command.dir/test_socket_command.cpp.o -[ 33%] Building CXX object CMakeFiles/test_core_include.dir/test_core_include.cpp.o -[ 50%] Building CXX object CMakeFiles/test_clock_sync_protocol.dir/test_clock_sync_protocol.cpp.o -[ 66%] Linking CXX executable test_core_include -[ 66%] Built target test_core_include -[ 83%] Linking CXX executable test_clock_sync_protocol -[ 83%] Built target test_clock_sync_protocol -[100%] Linking CXX executable test_socket_command -[100%] Built target test_socket_command -build_exit=0 -Internal ctest changing into directory: /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build -Test project /home/runner/work/ESPressio-Sockets/ESPressio-Sockets/build - Start 1: CoreWithoutCommand -1/3 Test #1: CoreWithoutCommand ............... Passed 0.00 sec - Start 2: SocketCommand -2/3 Test #2: SocketCommand .................... Passed 0.00 sec - Start 3: ClockSynchronizationProtocol -3/3 Test #3: ClockSynchronizationProtocol ..... Passed 0.00 sec - -100% tests passed, 0 tests failed out of 3 - -Total Test time (real) = 0.01 sec -test_exit=0