diff --git a/components/socket/README.md b/components/socket/README.md index b2333b92c..ba0cecd90 100644 --- a/components/socket/README.md +++ b/components/socket/README.md @@ -29,6 +29,12 @@ includes some initialization, cleanup, and conversion utilities. The socket class is subclassed into UdpSocket and TcpSocket. +It provides typed option setters (e.g. `set_receive_timeout(...)`, +`set_receive_buffer_size(...)`, `set_reuse_address(...)`, and +`set_dscp(...)` / `get_dscp()` for marking transmitted packets with a +DiffServ code point such as `espp::Dscp::Ef`) plus a generic `set_option()` +wrapper, so callers never need the native handle for common configuration. + ## UDP Socket UDP sockets provide unreliable, unordered communication over IP network sockets. diff --git a/components/socket/include/socket.hpp b/components/socket/include/socket.hpp index b6b5506f3..72133fe05 100644 --- a/components/socket/include/socket.hpp +++ b/components/socket/include/socket.hpp @@ -27,6 +27,7 @@ typedef int sock_type_t; #include #include "base_component.hpp" +#include "dscp.hpp" #include "format.hpp" namespace espp { @@ -239,6 +240,28 @@ class Socket : public BaseComponent { */ std::optional get_receive_buffer_size(); + /** + * @brief Mark this socket's TRANSMITTED packets with a DSCP code point + * (applied as IP_TOS - the DSCP occupies the upper 6 bits of the + * TOS / Traffic Class byte, RFC 2474). + * @note Best-effort: affects network / driver treatment of outgoing traffic + * (e.g. Dscp::Ef "expedited forwarding" for latency-critical flows), + * NOT local scheduling. Some platforms may ignore it. + * @param dscp the code point to apply. A custom (non-standard) value can be + * expressed with static_cast(0-63); values above 63 are + * rejected (returns false with a log) rather than silently masked to + * a different code point. + * @return true if IP_TOS was successfully set. + */ + bool set_dscp(espp::Dscp dscp); + + /** + * @brief Get the DSCP code point this socket marks its transmitted packets + * with (read back from IP_TOS; the lower 2 ECN bits are discarded). + * @return the code point, or std::nullopt on failure. + */ + std::optional get_dscp(); + /** * @brief Allow others to use this address/port combination after we're done * with it. diff --git a/components/socket/src/socket.cpp b/components/socket/src/socket.cpp index 584ba1e27..8bfc96ca5 100644 --- a/components/socket/src/socket.cpp +++ b/components/socket/src/socket.cpp @@ -228,6 +228,37 @@ std::optional Socket::get_receive_buffer_size() { return static_cast(value); } +bool Socket::set_dscp(espp::Dscp dscp) { + const auto code_point = static_cast(dscp); + if (code_point > 63) { + // Named espp::Dscp values are always in range; a custom static_cast'd + // code point is documented as 0-63. Silently masking would apply a + // DIFFERENT code point (64 -> 0, 255 -> 63), so reject invalid values. + logger_.error("set_dscp: invalid DSCP {} (valid range 0-63); not applied", code_point); + return false; + } + const int tos = espp::dscp_to_tos(dscp); + return set_option(IPPROTO_IP, IP_TOS, tos); +} + +std::optional Socket::get_dscp() { + int value = 0; +#if defined(_WIN32) + // Winsock's getsockopt takes the optlen as int*, not socklen_t*. + int len = sizeof(value); + int err = getsockopt(socket_, IPPROTO_IP, IP_TOS, reinterpret_cast(&value), &len); +#else + socklen_t len = sizeof(value); + int err = getsockopt(socket_, IPPROTO_IP, IP_TOS, &value, &len); +#endif + if (err < 0) { + logger_.error("Couldn't get IP_TOS: {}", error_string()); + return {}; + } + // the DSCP is the upper 6 bits of the TOS byte (the lower 2 are ECN) + return static_cast((static_cast(value) >> 2) & 0x3F); +} + bool Socket::disable_reuse() { #if !CONFIG_LWIP_SO_REUSE && defined(ESP_PLATFORM) // reuse is not compiled into lwip, so it is already effectively disabled diff --git a/components/socket/src/socket_reactor.cpp b/components/socket/src/socket_reactor.cpp index 865bb1fb3..c60f7513f 100644 --- a/components/socket/src/socket_reactor.cpp +++ b/components/socket/src/socket_reactor.cpp @@ -204,23 +204,13 @@ SocketReactor::add_udp_receiver(espp::UdpSocket &socket, sock_type_t fd = socket.native_handle(); if (receive_config.dscp.has_value()) { // Mark this socket's transmitted packets (e.g. echo responses) with the - // requested DSCP code point. The TOS byte carries the 6-bit DSCP in its - // upper bits (RFC 2474). Best-effort: network / driver treatment only, no - // effect on local scheduling (that is what `band` is for). - const uint8_t dscp = static_cast(receive_config.dscp.value()); - if (dscp > 63) { - // Named espp::Dscp values are always in range; a custom static_cast'd - // code point is documented as 0-63. Silently masking would apply a - // DIFFERENT code point (64 -> 0, 255 -> 63), so ignore invalid values. - logger_.warn("add_udp_receiver: invalid DSCP {} (valid range 0-63) on port {}; not applied", - dscp, receive_config.port); - } else { - const int tos = espp::dscp_to_tos(receive_config.dscp.value()); - if (::setsockopt(fd, IPPROTO_IP, IP_TOS, reinterpret_cast(&tos), sizeof(tos)) < - 0) { - logger_.warn("add_udp_receiver: could not set IP_TOS (DSCP {}) on port {}", dscp, - receive_config.port); - } + // requested DSCP code point. Best-effort: network / driver treatment + // only, no effect on local scheduling (that is what `band` is for). + // Socket::set_dscp() validates the code point and logs specifics; + // registration proceeds either way. + if (!socket.set_dscp(receive_config.dscp.value())) { + logger_.warn("add_udp_receiver: could not apply DSCP {} on port {}", + static_cast(receive_config.dscp.value()), receive_config.port); } } auto handler = [this, &socket, callback, buffer_size]() { diff --git a/lib/autogenerate_bindings.py b/lib/autogenerate_bindings.py index 0cd3ee9ad..b46077344 100644 --- a/lib/autogenerate_bindings.py +++ b/lib/autogenerate_bindings.py @@ -310,8 +310,56 @@ def _add_gil_release_guards(code: str) -> str: return code +# litgen does not emit base classes for the socket hierarchy; declare them so the +# python-side UdpSocket/TcpSocket inherit the base Socket methods (set_dscp, +# set_receive_timeout, native_handle, ...). +_BASE_CLASS_FIX = { + "py::class_(": "py::class_(", + "py::class_(": "py::class_(", +} + + +def _fix_base_classes(code: str) -> str: + # Like _fix_implicit_default_ctors: each pattern must apply exactly once. A + # count of 0 means litgen's output drifted and python-side inheritance + # would silently regress; > 1 means the pattern matched something it + # should not have. Warn loudly either way so regeneration surfaces it. + for old, new in _BASE_CLASS_FIX.items(): + n = code.count(old) + if n != 1: + print(f"WARNING: base-class fix `{old}` applied {n} times (expected 1); " + "python UdpSocket/TcpSocket may not inherit Socket - update _BASE_CLASS_FIX") + code = code.replace(old, new) + return code + + +# The stub (.pyi) is generated alongside the pydef and needs the same base-class +# treatment: litgen emits `class TcpSocket:` / `class UdpSocket:` without the +# Socket base, so type checkers / IDEs would not see the inherited Socket +# methods (set_dscp, set_receive_timeout, ...). Extend this map as further +# developer-facing stub fixes are needed. +_STUB_FIX = { + "\nclass TcpSocket:\n": "\nclass TcpSocket(Socket):\n", + "\nclass UdpSocket:\n": "\nclass UdpSocket(Socket):\n", +} + + +def _postprocess_generated_stub(code: str) -> str: + # Same exactly-once contract (and loud warning) as _fix_base_classes: a + # count of 0 means litgen's stub output drifted and the committed + # inheritance would silently regress on regeneration. + for old, new in _STUB_FIX.items(): + n = code.count(old) + if n != 1: + print(f"WARNING: stub fix {old!r} applied {n} times (expected 1); " + "the generated .pyi may lose fixes - update _STUB_FIX") + code = code.replace(old, new) + return code + + def _postprocess_generated(code: str) -> str: code = _fix_implicit_default_ctors(code) + code = _fix_base_classes(code) code = _fix_template_class_nested(code) code = _remove_static_instance_dups(code) code = _fix_class_holders(code) @@ -550,6 +598,16 @@ def autogenerate() -> None: with open(pydef_file, "w") as f: f.write(code) print(f"Post-processed {pydef_file}") + + # The stub is a generated artifact too - reapply its fixes so regeneration + # preserves them (see _postprocess_generated_stub). + stub_file = output_dir + "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/espp/__init__.pyi" + with open(stub_file, "r") as f: + stub = f.read() + stub = _postprocess_generated_stub(stub) + with open(stub_file, "w") as f: + f.write(stub) + print(f"Post-processed {stub_file}") # NOTE: all fixups are applied statically in _postprocess_generated() above, so the generated # file compiles with no manual edits and without a build step. fix_generated_bindings.py is kept # as an optional diagnostic: if a future litgen version introduces *new* unqualified names, run diff --git a/lib/python_bindings/espp/__init__.pyi b/lib/python_bindings/espp/__init__.pyi index 4aa00bb52..d6a780f91 100644 --- a/lib/python_bindings/espp/__init__.pyi +++ b/lib/python_bindings/espp/__init__.pyi @@ -2916,6 +2916,24 @@ class Socket: """ pass + def set_dscp(self, dscp: Dscp) -> bool: + """* + * @brief Mark this socket's TRANSMITTED packets with a DSCP code point + * (applied as IP_TOS - RFC 2474). Best-effort: network / driver + * treatment of outgoing traffic (e.g. Dscp.Ef), NOT local scheduling. + * @param dscp the espp.Dscp code point to apply. + * @return True if IP_TOS was successfully set. + """ + pass + + def get_dscp(self) -> Optional[Dscp]: + """* + * @brief Get the DSCP code point this socket marks its transmitted packets + * with (read back from IP_TOS; ECN bits discarded). + * @return The espp.Dscp code point, or None on failure. + """ + pass + def enable_reuse(self) -> bool: """* * @brief Allow others to use this address/port combination after we're done @@ -2996,7 +3014,7 @@ class Socket: -class TcpSocket: +class TcpSocket(Socket): """* * @brief Class for managing sending and receiving data using TCP/IP. Can be * used to create client or server sockets. @@ -3266,7 +3284,7 @@ class TcpSocket: # TODO: should this class _contain_ a socket or just create sockets within each # call? -class UdpSocket: +class UdpSocket(Socket): """* * @brief Class for managing sending and receiving data using UDP/IP. Can be * used to create client or server sockets. diff --git a/lib/python_bindings/pybind_espp.cpp b/lib/python_bindings/pybind_espp.cpp index 1a6ae5b2c..38c1ab81e 100644 --- a/lib/python_bindings/pybind_espp.cpp +++ b/lib/python_bindings/pybind_espp.cpp @@ -1726,6 +1726,16 @@ void py_init_module_espp(py::module &m) { "*\n * @brief Set the receive timeout on the provided socket.\n * @param timeout " "requested timeout, must be > 0.\n * @return True if SO_RECVTIMEO was successfully " "set.\n") + .def("set_dscp", &espp::Socket::set_dscp, py::arg("dscp"), + "*\n * @brief Mark this socket's TRANSMITTED packets with a DSCP code point\n * " + " (applied as IP_TOS - RFC 2474). Best-effort: network / driver\n * " + "treatment of outgoing traffic (e.g. Dscp.Ef), NOT local scheduling.\n * @param " + "dscp the espp.Dscp code point to apply.\n * @return True if IP_TOS was " + "successfully set.\n") + .def("get_dscp", &espp::Socket::get_dscp, + "*\n * @brief Get the DSCP code point this socket marks its transmitted packets\n " + "* with (read back from IP_TOS; ECN bits discarded).\n * @return The " + "espp.Dscp code point, or None on failure.\n") .def("enable_reuse", &espp::Socket::enable_reuse, "*\n * @brief Allow others to use this address/port combination after we're done\n " "* with it.\n * @return True if SO_REUSEADDR and SO_REUSEPORT were " @@ -1762,7 +1772,7 @@ void py_init_module_espp(py::module &m) { //////////////////// //////////////////// //////////////////// //////////////////// - auto pyClassTcpSocket = py::class_( + auto pyClassTcpSocket = py::class_( m, "TcpSocket", py::dynamic_attr(), "*\n * @brief Class for managing sending and receiving data using TCP/IP. Can be\n * " " used to create client or server sockets.\n *\n * \\section tcp_ex1 TCP Client Example\n " @@ -1929,7 +1939,7 @@ void py_init_module_espp(py::module &m) { //////////////////// //////////////////// //////////////////// //////////////////// - auto pyClassUdpSocket = py::class_( + auto pyClassUdpSocket = py::class_( m, "UdpSocket", py::dynamic_attr(), "*\n * @brief Class for managing sending and receiving data using UDP/IP. Can be\n * " " used to create client or server sockets.\n *\n * See\n * " diff --git a/pc/tests/socket_reactor.cpp b/pc/tests/socket_reactor.cpp index 741d7e5a3..9aadd9f89 100644 --- a/pc/tests/socket_reactor.cpp +++ b/pc/tests/socket_reactor.cpp @@ -254,21 +254,12 @@ int main() { "Critical-band receiver registered (with dscp)"); #if !defined(_WIN32) - // Verify the DSCP marking actually landed on the sockets: IP_TOS carries - // the DSCP in its upper 6 bits, so read it back with getsockopt(). - auto read_tos = [](espp::UdpSocket &s) { - int tos = -1; - socklen_t len = sizeof(tos); - if (::getsockopt(s.native_handle(), IPPROTO_IP, IP_TOS, reinterpret_cast(&tos), - &len) < 0) { - return -1; - } - return tos; - }; - check(read_tos(low_server) == espp::dscp_to_tos(espp::Dscp::Cs1), - "IP_TOS on the Low socket reflects Dscp::Cs1"); - check(read_tos(crit_server) == espp::dscp_to_tos(espp::Dscp::Ef), - "IP_TOS on the Critical socket reflects Dscp::Ef"); + // Verify the DSCP marking actually landed on the sockets, read back + // through the socket's own getter. + check(low_server.get_dscp() == espp::Dscp::Cs1, + "get_dscp() on the Low socket reflects Dscp::Cs1"); + check(crit_server.get_dscp() == espp::Dscp::Ef, + "get_dscp() on the Critical socket reflects Dscp::Ef"); // Out-of-range DSCP: registration must still succeed, but the invalid // value must be ignored (TOS left at the OS default), not masked into a // different code point. @@ -282,7 +273,17 @@ int main() { .dscp = static_cast(200)}); check(bad_dscp_id != espp::SocketReactor::INVALID_ID, "registration with an out-of-range DSCP still succeeds"); - check(read_tos(bad_dscp_server) == 0, "out-of-range DSCP is ignored (TOS stays default)"); + check(bad_dscp_server.get_dscp() == espp::Dscp::Cs0, + "out-of-range DSCP is ignored (TOS stays default)"); + // Direct Socket::set_dscp()/get_dscp() round-trip on the same socket, + // independent of the reactor: valid value applies, invalid is rejected + // without changing the previous one. + check(bad_dscp_server.set_dscp(espp::Dscp::Af41), "set_dscp(Af41) succeeds"); + check(bad_dscp_server.get_dscp() == espp::Dscp::Af41, "get_dscp() round-trips Af41"); + check(!bad_dscp_server.set_dscp(static_cast(64)), + "set_dscp(64) rejected (out of range)"); + check(bad_dscp_server.get_dscp() == espp::Dscp::Af41, + "rejected set_dscp leaves the previous code point in place"); // bad_dscp_server is scoped inside the reactor's block, so make sure its // registration is fully gone before it goes out of scope reactor.remove(bad_dscp_id);