diff --git a/src/cbdev/src/device_session.cpp b/src/cbdev/src/device_session.cpp index b008d4fe..f0a98d39 100644 --- a/src/cbdev/src/device_session.cpp +++ b/src/cbdev/src/device_session.cpp @@ -41,6 +41,7 @@ #include "cbdev/clock_sync.h" #include #include +#include #include #include #include @@ -204,6 +205,11 @@ struct DeviceSession::Impl { // Device configuration (from REQCONFIGALL) cbproto::DeviceConfig device_config{}; + // Wire protocol for OUTBOUND packets. PROTOCOL_CURRENT means send as-is; + // a legacy value makes sendPacket() down-translate. Set by protocol + // wrappers via setSendProtocol() so config helpers reach legacy firmware. + ProtocolVersion send_protocol = ProtocolVersion::PROTOCOL_CURRENT; + // Outbound pacing for configuration packets. A device's UDP receive buffer // can be as small as ~8 KB (~8 CHANINFO packets); a caller that sends many // per-channel setChannelConfig() packets back-to-back (e.g. clearing LNC / @@ -569,6 +575,12 @@ Result DeviceSession::receivePackets(void* buffer, const size_t buffer_size return result; } +void DeviceSession::setSendProtocol(ProtocolVersion version) { + if (m_impl) { + m_impl->send_protocol = version; + } +} + Result DeviceSession::sendPacket(const cbPKT_GENERIC& pkt) { if (!m_impl || !m_impl->connected) { return Result::error("Device not connected"); @@ -589,6 +601,17 @@ Result DeviceSession::sendPacket(const cbPKT_GENERIC& pkt) { m_impl->last_config_send = std::chrono::steady_clock::now(); } + // Legacy device: down-translate the current-format packet to the device's + // wire protocol. This is the single choke point for outbound translation: + // the high-level config helpers (setChannelConfig, setSystemRunLevelSync, + // …) all funnel through sendPacket()/sendPackets(), so routing translation + // here makes them work even when this session is wrapped by a protocol + // translator that delegates those helpers to us. + if (m_impl->send_protocol != ProtocolVersion::PROTOCOL_CURRENT && + m_impl->send_protocol != ProtocolVersion::UNKNOWN) { + return sendTranslated(pkt); + } + // Non-Gemini: convert nanosecond timestamp back to device clock ticks. // This is the inverse of the ticks→ns conversion in receivePackets(). if (!m_impl->timestamps_are_nanoseconds && m_impl->ts_convert_den > 1 && @@ -606,6 +629,66 @@ Result DeviceSession::sendPacket(const cbPKT_GENERIC& pkt) { return sendRaw(&pkt, packet_size); } +Result DeviceSession::sendTranslated(const cbPKT_GENERIC& pkt) { + using cbproto::PacketTranslator; + using cbproto::cbPKT_HEADER_311; + using cbproto::cbPKT_HEADER_400; + using cbproto::HEADER_SIZE_311; + using cbproto::HEADER_SIZE_400; + using cbproto::HEADER_SIZE_410; + + uint8_t dest[cbPKT_MAX_SIZE]; + + switch (m_impl->send_protocol) { + case ProtocolVersion::PROTOCOL_311: { + // 3.11 header: 32-bit tick timestamp, 8-bit type, 8-bit dlen. + if (pkt.cbpkt_header.type > 0xFF) { + return Result::error("Packet type too large for protocol 3.11 (max 255)"); + } + if (pkt.cbpkt_header.dlen > 0xFF) { + return Result::error("Packet dlen too large for protocol 3.11 (max 255)"); + } + auto& dest_header = *reinterpret_cast(&dest[0]); + dest_header.time = static_cast(pkt.cbpkt_header.time * 30000 / 1000000000); + dest_header.chid = pkt.cbpkt_header.chid; + dest_header.type = static_cast(pkt.cbpkt_header.type); // Narrowing! + dest_header.dlen = static_cast(pkt.cbpkt_header.dlen); // Narrowing! + const size_t dest_dlen = PacketTranslator::translatePayload_current_to_311(pkt, dest); + dest_header.dlen = static_cast(dest_dlen); + return sendRaw(dest, HEADER_SIZE_311 + dest_header.dlen * 4); + } + case ProtocolVersion::PROTOCOL_400: { + // 4.0 header: 64-bit timestamp, 8-bit type, 16-bit dlen, 16-bit reserved. + auto& dest_header = *reinterpret_cast(&dest[0]); + dest_header.time = pkt.cbpkt_header.time; + dest_header.chid = pkt.cbpkt_header.chid; + dest_header.type = static_cast(pkt.cbpkt_header.type); + dest_header.dlen = pkt.cbpkt_header.dlen; + dest_header.instrument = pkt.cbpkt_header.instrument; + dest_header.reserved = static_cast(pkt.cbpkt_header.reserved); + const size_t dest_dlen = PacketTranslator::translatePayload_current_to_400(pkt, dest); + dest_header.dlen = static_cast(dest_dlen); + return sendRaw(dest, HEADER_SIZE_400 + dest_header.dlen * 4); + } + case ProtocolVersion::PROTOCOL_410: { + // 4.1 header is identical to current; only the payload may differ. + cbPKT_GENERIC out; + std::memcpy(&out, &pkt, sizeof(cbPKT_GENERIC)); + auto* out_bytes = reinterpret_cast(&out); + const size_t dest_dlen = PacketTranslator::translatePayload_current_to_410(pkt, out_bytes); + out.cbpkt_header.dlen = dest_dlen; + return sendRaw(out_bytes, HEADER_SIZE_410 + out.cbpkt_header.dlen * 4); + } + default: + break; + } + + // send_protocol is current/unknown — send unchanged (should not happen: the + // caller only routes here for legacy protocols). + const size_t packet_size = cbPKT_HEADER_SIZE + (pkt.cbpkt_header.dlen * 4); + return sendRaw(&pkt, packet_size); +} + Result DeviceSession::sendPackets(const std::vector& pkts) { if (pkts.empty()) { return Result::error("Empty packet vector"); diff --git a/src/cbdev/src/device_session_311.cpp b/src/cbdev/src/device_session_311.cpp index b5c71427..2780cca5 100644 --- a/src/cbdev/src/device_session_311.cpp +++ b/src/cbdev/src/device_session_311.cpp @@ -137,32 +137,6 @@ Result DeviceSession_311::receivePackets(void* buffer, const size_t buffer_ return Result::ok(static_cast(dest_offset)); } -Result DeviceSession_311::sendPacket(const cbPKT_GENERIC& pkt) { - // Translate current format to 3.11 format - uint8_t dest[cbPKT_MAX_SIZE]; - - if (pkt.cbpkt_header.type > 0xFF) { - return Result::error("Packet type too large for protocol 3.11 (max 255)"); - } - if (pkt.cbpkt_header.dlen > 0xFF) { - return Result::error("Packet dlen too large for protocol 3.11 (max 255)"); - } - - // -- Header -- - auto& dest_header = *reinterpret_cast(&dest[0]); - dest_header.time = static_cast(pkt.cbpkt_header.time * 30000 / 1000000000); - dest_header.chid = pkt.cbpkt_header.chid; - dest_header.type = static_cast(pkt.cbpkt_header.type); // Narrowing! - dest_header.dlen = static_cast(pkt.cbpkt_header.dlen); // Narrowing! - - // -- Payload -- - const size_t dest_dlen = PacketTranslator::translatePayload_current_to_311(pkt, dest); - dest_header.dlen = dest_dlen; - - // Send raw bytes directly via the device's sendRaw method - return m_device.sendRaw(dest, HEADER_SIZE_311 + dest_header.dlen * 4); -} - Result DeviceSession_311::sendRaw(const void* buffer, const size_t size) { // Pass through to underlying device return m_device.sendRaw(buffer, size); diff --git a/src/cbdev/src/device_session_311.h b/src/cbdev/src/device_session_311.h index 7fa16cd0..e68ddd5a 100644 --- a/src/cbdev/src/device_session_311.h +++ b/src/cbdev/src/device_session_311.h @@ -57,9 +57,6 @@ class DeviceSession_311 : public DeviceSessionWrapper { /// Receive packets from device and translate from 3.11 to current format Result receivePackets(void* buffer, size_t buffer_size) override; - /// Send packet to device, translating from current to 3.11 format - Result sendPacket(const cbPKT_GENERIC& pkt) override; - /// Send raw bytes (pass-through to underlying device) Result sendRaw(const void* buffer, size_t size) override; @@ -71,7 +68,11 @@ class DeviceSession_311 : public DeviceSessionWrapper { private: /// Private constructor taking a DeviceSession explicit DeviceSession_311(DeviceSession&& device) - : DeviceSessionWrapper(std::move(device)) {} + : DeviceSessionWrapper(std::move(device)) { + // Outbound packets (incl. those from delegated config helpers) are + // down-translated to 3.11 by the wrapped session. + m_device.setSendProtocol(ProtocolVersion::PROTOCOL_311); + } }; } // namespace cbdev diff --git a/src/cbdev/src/device_session_400.cpp b/src/cbdev/src/device_session_400.cpp index e372b66a..d987c1d7 100644 --- a/src/cbdev/src/device_session_400.cpp +++ b/src/cbdev/src/device_session_400.cpp @@ -133,35 +133,6 @@ Result DeviceSession_400::receivePackets(void* buffer, const size_t buffer_ return Result::ok(static_cast(dest_offset)); } -Result DeviceSession_400::sendPacket(const cbPKT_GENERIC& pkt) { - // Translate current format to 4.0 format - uint8_t temp_buffer[cbPKT_MAX_SIZE]; - - // -- Header -- - // Read current format header fields - /// When going from current to 4.0, we fix the header as follows: - /// 1. Read 16-bit type from bytes 11-12, shift right 8 bits to get 8-bit type, set on byte 11. - /// 2. Read dlen from bytes 13-14, write to bytes 12-13. - /// 3. Read instrument from byte 15, write to byte 14. - /// 4. Read reserved from byte 16, write to bytes 15-16 as 16-bit. - auto& dest_header = *reinterpret_cast(temp_buffer); - dest_header.time = pkt.cbpkt_header.time; // TODO: What if we are using time ticks, not nanoseconds? - dest_header.chid = pkt.cbpkt_header.chid; - dest_header.type = static_cast(pkt.cbpkt_header.type); - dest_header.dlen = pkt.cbpkt_header.dlen; - dest_header.instrument = pkt.cbpkt_header.instrument; - dest_header.reserved = static_cast(pkt.cbpkt_header.reserved); - - // -- Payload -- - auto* dest_payload = &temp_buffer[HEADER_SIZE_400]; - const size_t dest_dlen = PacketTranslator::translatePayload_current_to_400(pkt, dest_payload); - dest_header.dlen = dest_dlen; - const size_t packet_size_400 = HEADER_SIZE_400 + dest_header.dlen * 4; - - // Send raw bytes directly via the device's sendRaw method - return m_device.sendRaw(temp_buffer, packet_size_400); -} - Result DeviceSession_400::sendRaw(const void* buffer, const size_t size) { // Pass through to underlying device return m_device.sendRaw(buffer, size); diff --git a/src/cbdev/src/device_session_400.h b/src/cbdev/src/device_session_400.h index 01587f92..8e3f83e5 100644 --- a/src/cbdev/src/device_session_400.h +++ b/src/cbdev/src/device_session_400.h @@ -62,9 +62,6 @@ class DeviceSession_400 : public DeviceSessionWrapper { /// Receive packets from device and translate from 4.0 to current format Result receivePackets(void* buffer, size_t buffer_size) override; - /// Send packet to device, translating from current to 4.0 format - Result sendPacket(const cbPKT_GENERIC& pkt) override; - /// Send raw bytes (pass-through to underlying device) Result sendRaw(const void* buffer, size_t size) override; @@ -76,7 +73,11 @@ class DeviceSession_400 : public DeviceSessionWrapper { private: /// Private constructor taking a DeviceSession explicit DeviceSession_400(DeviceSession&& device) - : DeviceSessionWrapper(std::move(device)) {} + : DeviceSessionWrapper(std::move(device)) { + // Outbound packets (incl. those from delegated config helpers) are + // down-translated to 4.0 by the wrapped session. + m_device.setSendProtocol(ProtocolVersion::PROTOCOL_400); + } }; } // namespace cbdev diff --git a/src/cbdev/src/device_session_410.cpp b/src/cbdev/src/device_session_410.cpp index b4949544..2493a43a 100644 --- a/src/cbdev/src/device_session_410.cpp +++ b/src/cbdev/src/device_session_410.cpp @@ -98,20 +98,6 @@ Result DeviceSession_410::receivePackets(void* buffer, const size_t buffer_ return Result::ok(static_cast(offset)); } -Result DeviceSession_410::sendPacket(const cbPKT_GENERIC& pkt) { - // Formats are nearly identical. - // Nevertheless, the src pkt is const so we make a copy to modify. - cbPKT_GENERIC new_pkt; - std::memcpy(&new_pkt, &pkt, sizeof(cbPKT_GENERIC)); - auto* pkt_bytes = reinterpret_cast(&new_pkt); - const size_t dest_dlen = PacketTranslator::translatePayload_current_to_410(pkt, pkt_bytes); - new_pkt.cbpkt_header.dlen = dest_dlen; - const size_t packet_size_410 = HEADER_SIZE_410 + new_pkt.cbpkt_header.dlen * 4; - - // Send raw bytes directly via the device's sendRaw method - return m_device.sendRaw(pkt_bytes, packet_size_410); -} - Result DeviceSession_410::sendRaw(const void* buffer, const size_t size) { // Pass through to underlying device return m_device.sendRaw(buffer, size); diff --git a/src/cbdev/src/device_session_410.h b/src/cbdev/src/device_session_410.h index d906ba8f..f8105383 100644 --- a/src/cbdev/src/device_session_410.h +++ b/src/cbdev/src/device_session_410.h @@ -62,9 +62,6 @@ class DeviceSession_410 : public DeviceSessionWrapper { /// Receive packets from device and translate from 4.10 to current format Result receivePackets(void* buffer, size_t buffer_size) override; - /// Send packet to device, translating from current to 4.10 format - Result sendPacket(const cbPKT_GENERIC& pkt) override; - /// Send raw bytes (pass-through to underlying device) Result sendRaw(const void* buffer, size_t size) override; @@ -76,7 +73,11 @@ class DeviceSession_410 : public DeviceSessionWrapper { private: /// Private constructor taking a DeviceSession explicit DeviceSession_410(DeviceSession&& device) - : DeviceSessionWrapper(std::move(device)) {} + : DeviceSessionWrapper(std::move(device)) { + // Outbound packets (incl. those from delegated config helpers) are + // down-translated to 4.10 by the wrapped session. + m_device.setSendProtocol(ProtocolVersion::PROTOCOL_410); + } }; } // namespace cbdev diff --git a/src/cbdev/src/device_session_impl.h b/src/cbdev/src/device_session_impl.h index 7eb505e2..0343a057 100644 --- a/src/cbdev/src/device_session_impl.h +++ b/src/cbdev/src/device_session_impl.h @@ -107,6 +107,16 @@ class DeviceSession : public IDeviceSession { /// @return Protocol version (PROTOCOL_CURRENT for this session) [[nodiscard]] ProtocolVersion getProtocolVersion() const override; + /// Set the wire protocol used when sending packets. + /// Defaults to PROTOCOL_CURRENT (no translation). Protocol wrappers set + /// this to their device's version so that sendPacket()/sendPackets() — + /// and therefore every high-level config helper that funnels through them + /// — down-translate outgoing packets to the legacy wire format. Without + /// this, helpers delegated to the wrapped session would send current-format + /// packets that legacy firmware cannot parse. + /// @param version Target wire protocol for outbound packets + void setSendProtocol(ProtocolVersion version); + /// Get full device configuration [[nodiscard]] const cbproto::DeviceConfig& getDeviceConfig() const override; @@ -340,6 +350,12 @@ class DeviceSession : public IDeviceSession { size_t count = 1 ); + /// Down-translate a current-format packet to m_impl->send_protocol and send + /// it. Only called from sendPacket() when send_protocol != PROTOCOL_CURRENT. + /// @param pkt Packet in current (4.1+) wire format + /// @return Success or error + Result sendTranslated(const cbPKT_GENERIC& pkt); + /// Implementation details (pImpl pattern) struct Impl; std::unique_ptr m_impl; diff --git a/src/cbdev/src/device_session_wrapper.h b/src/cbdev/src/device_session_wrapper.h index 2c073b55..6c1fb1e0 100644 --- a/src/cbdev/src/device_session_wrapper.h +++ b/src/cbdev/src/device_session_wrapper.h @@ -69,16 +69,28 @@ class DeviceSessionWrapper : public IDeviceSession { /// Subclasses MUST override to translate from protocol format → current format Result receivePackets(void* buffer, size_t buffer_size) override = 0; - /// Send packet with protocol translation - /// Subclasses MUST override to translate from current format → protocol format - Result sendPacket(const cbPKT_GENERIC& pkt) override = 0; - /// Get protocol version /// Subclasses MUST override to return their specific protocol version [[nodiscard]] ProtocolVersion getProtocolVersion() const override = 0; /// @} + /////////////////////////////////////////////////////////////////////////////////////////////////// + /// @name Auto-Delegated Send Path (Same for All Protocols) + /// @{ + + /// Send a packet, down-translating to the device's wire protocol. + /// Delegates to the wrapped session, which performs the current→legacy + /// translation (its send protocol was set at construction). Keeping the + /// translation in one place — DeviceSession::sendPacket — ensures the + /// high-level config helpers, which are also delegated to m_device and call + /// sendPacket() internally, translate identically. + Result sendPacket(const cbPKT_GENERIC& pkt) override { + return m_device.sendPacket(pkt); + } + + /// @} + /////////////////////////////////////////////////////////////////////////////////////////////////// /// @name Auto-Delegated Methods (Same for All Protocols) /// @{ diff --git a/src/cbproto/include/cbproto/packet_translator.h b/src/cbproto/include/cbproto/packet_translator.h index f387d5af..7e859220 100644 --- a/src/cbproto/include/cbproto/packet_translator.h +++ b/src/cbproto/include/cbproto/packet_translator.h @@ -14,13 +14,17 @@ namespace cbproto { +// These describe on-the-wire byte layouts, so they MUST be byte-packed — the +// current-protocol structs in cbproto/types.h are all under #pragma pack(1). +// Without this, natural alignment inflates cbPKT_HEADER_400 to 24 bytes (dlen +// lands at offset 12 instead of 11), corrupting every translated 4.0 packet. +#pragma pack(push, 1) typedef struct { uint32_t time; ///< Ticks at 30 kHz uint16_t chid; ///< Channel identifier uint8_t type; ///< Packet type uint8_t dlen; ///< Length of data field in 32-bit chunks } cbPKT_HEADER_311; -constexpr size_t HEADER_SIZE_311 = sizeof(cbPKT_HEADER_311); typedef struct { PROCTIME time; ///< Ticks at 30 kHz on legacy, or nanoseconds on Gemini @@ -30,8 +34,14 @@ typedef struct { uint8_t instrument; ///< Instrument identifier uint16_t reserved; ///< Reserved byte } cbPKT_HEADER_400; +#pragma pack(pop) + +constexpr size_t HEADER_SIZE_311 = sizeof(cbPKT_HEADER_311); constexpr size_t HEADER_SIZE_400 = sizeof(cbPKT_HEADER_400); +static_assert(HEADER_SIZE_311 == 8, "cbPKT_HEADER_311 must be 8 bytes on the wire"); +static_assert(HEADER_SIZE_400 == 16, "cbPKT_HEADER_400 must be 16 bytes on the wire"); + constexpr size_t HEADER_SIZE_410 = cbPKT_HEADER_SIZE; // Header unchanged since 4.1 diff --git a/tests/unit/test_device_session.cpp b/tests/unit/test_device_session.cpp index e97bbe8f..1a6d5ae9 100644 --- a/tests/unit/test_device_session.cpp +++ b/tests/unit/test_device_session.cpp @@ -18,8 +18,89 @@ #include #include +#ifdef _WIN32 + #include + #include + using fake_socket_t = SOCKET; + static constexpr fake_socket_t kInvalidFakeSocket = INVALID_SOCKET; +#else + #include + #include + #include + #include + #include + using fake_socket_t = int; + static constexpr fake_socket_t kInvalidFakeSocket = -1; +#endif + using namespace cbdev; +namespace { + +/// Minimal RAII UDP "device": binds to 127.0.0.1:port and exposes recvOne(). +/// Used to capture the exact bytes a DeviceSession puts on the wire so we can +/// assert they were down-translated to the legacy protocol. Create it AFTER a +/// DeviceSession exists so Winsock is already initialised on Windows. +class FakeDeviceSocket { +public: + explicit FakeDeviceSocket(uint16_t port) { + m_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (m_sock == kInvalidFakeSocket) return; + + // 1 s receive timeout so a missing packet fails the test instead of hanging. +#ifdef _WIN32 + DWORD tv = 1000; + setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&tv), sizeof(tv)); +#else + timeval tv{}; + tv.tv_sec = 1; + setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + m_bound = bind(m_sock, reinterpret_cast(&addr), sizeof(addr)) == 0; + } + + ~FakeDeviceSocket() { + if (m_sock != kInvalidFakeSocket) { +#ifdef _WIN32 + closesocket(m_sock); +#else + ::close(m_sock); +#endif + } + } + + [[nodiscard]] bool ok() const { return m_sock != kInvalidFakeSocket && m_bound; } + + /// Block (up to the 1 s timeout) for one datagram. Returns bytes received, or <=0. + int recvOne(uint8_t* buf, size_t buflen) { + return static_cast(recvfrom(m_sock, reinterpret_cast(buf), + static_cast(buflen), 0, nullptr, nullptr)); + } + + FakeDeviceSocket(const FakeDeviceSocket&) = delete; + FakeDeviceSocket& operator=(const FakeDeviceSocket&) = delete; + +private: + fake_socket_t m_sock = kInvalidFakeSocket; + bool m_bound = false; +}; + +/// Read a little-endian uint16 from a byte offset. +uint16_t readU16LE(const uint8_t* p) { + return static_cast(p[0] | (static_cast(p[1]) << 8)); +} + +// Legacy on-wire header sizes (bytes). +constexpr size_t kHeaderSize311 = 8; // time(32) chid(16) type(8) dlen(8) +constexpr size_t kHeaderSize400 = 16; // time(64) chid(16) type(8) dlen(16) instr(8) rsvd(16) + +} // namespace + /// Test fixture for DeviceSession tests class DeviceSessionTest : public ::testing::Test { protected: @@ -296,3 +377,71 @@ TEST_F(DeviceSessionTest, ConfigSends_ArePaced_DataSends_AreNot) { EXPECT_LT(data_ms, 4) << "data sends should not be throttled"; EXPECT_GT(cfg_ms, data_ms) << "config sends should be slower than data sends"; } + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Legacy-protocol outbound translation +// +// Regression tests for the backward-compat bug where high-level config helpers +// (setSystemRunLevel, setChannelConfig, sync, …) reached legacy firmware in the +// CURRENT wire format. Those helpers are delegated to the wrapped DeviceSession +// and call sendPacket() internally; the fix routes that send through the +// wrapped session's protocol-aware sendPacket(). These tests drive a *delegated* +// helper (setSystemRunLevel) — not sendPacket() directly — and assert the bytes +// on the wire use the legacy header layout. Before the fix they arrived as +// current-format packets and these assertions fail. +/////////////////////////////////////////////////////////////////////////////////////////////////// + +TEST_F(DeviceSessionTest, LegacyConfigHelper_TranslatesOutbound_311) { + // Client sends to device_address:send_port — bind our fake device there. + const uint16_t send_port = 51041; + auto config = ConnectionParams::custom("127.0.0.1", "0.0.0.0", 51042, send_port); + auto result = createDeviceSession(config, ProtocolVersion::PROTOCOL_311); + ASSERT_TRUE(result.isOk()) << result.error(); + auto& session = result.value(); + + FakeDeviceSocket device(send_port); + ASSERT_TRUE(device.ok()) << "Failed to bind fake device socket"; + + // Delegated config helper — NOT sendPacket() directly. + ASSERT_TRUE(session->setSystemRunLevel(cbRUNLEVEL_RUNNING, 0, 0).isOk()); + + uint8_t buf[cbPKT_MAX_SIZE] = {}; + const int n = device.recvOne(buf, sizeof(buf)); + ASSERT_GT(n, 0) << "No datagram received from config helper"; + + // 3.11 header (8 bytes): time(0-3) chid(4-5) type(6) dlen(7). + // In CURRENT format byte[6] is part of the 64-bit timestamp (==0 here), so + // a non-zero SYSSETRUNLEV type at offset 6 proves 3.11 translation happened. + EXPECT_EQ(buf[6], static_cast(cbPKTTYPE_SYSSETRUNLEV)); + EXPECT_EQ(buf[7], static_cast(cbPKTDLEN_SYSINFO)); + EXPECT_EQ(readU16LE(&buf[4]), static_cast(cbPKTCHAN_CONFIGURATION)); + EXPECT_EQ(static_cast(n), kHeaderSize311 + cbPKTDLEN_SYSINFO * 4); +} + +TEST_F(DeviceSessionTest, LegacyConfigHelper_TranslatesOutbound_400) { + const uint16_t send_port = 51043; + auto config = ConnectionParams::custom("127.0.0.1", "0.0.0.0", 51044, send_port); + auto result = createDeviceSession(config, ProtocolVersion::PROTOCOL_400); + ASSERT_TRUE(result.isOk()) << result.error(); + auto& session = result.value(); + + FakeDeviceSocket device(send_port); + ASSERT_TRUE(device.ok()) << "Failed to bind fake device socket"; + + ASSERT_TRUE(session->setSystemRunLevel(cbRUNLEVEL_RUNNING, 0, 0).isOk()); + + uint8_t buf[cbPKT_MAX_SIZE] = {}; + const int n = device.recvOne(buf, sizeof(buf)); + ASSERT_GT(n, 0) << "No datagram received from config helper"; + + // 4.0 header (16 bytes): time(0-7) chid(8-9) type(8-bit @10) dlen(16-bit @11-12) + // instrument(13) reserved(14-15). + // The discriminator vs CURRENT is the dlen position: CURRENT puts a 16-bit + // type at 10-11 (so byte[11]==0) and dlen at 12-13. Reading a non-zero + // SYSINFO dlen at offset 11 proves the 4.0 layout — i.e. the helper's send + // was down-translated to 4.0. + EXPECT_EQ(buf[10], static_cast(cbPKTTYPE_SYSSETRUNLEV)); + EXPECT_EQ(readU16LE(&buf[11]), static_cast(cbPKTDLEN_SYSINFO)); + EXPECT_EQ(readU16LE(&buf[8]), static_cast(cbPKTCHAN_CONFIGURATION)); + EXPECT_EQ(static_cast(n), kHeaderSize400 + cbPKTDLEN_SYSINFO * 4); +}