From 9c1e9c3d3c0ae46d78169b3f2d696bc310400c51 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 27 May 2026 04:00:00 +0200 Subject: [PATCH 1/6] fix: Encode T330 shock intensity as 4-bit level with rolling state The T330 collar variant reads shock intensity from the lower nibble (0-15) of the intensity byte, not as a raw 0-100 value. The upper bits carry a rolling counter and toggle bit. Map 0-100 intensity to 0-15 level in the lower nibble and maintain per-shocker rolling state (counter + toggle) in the upper bits. Vibrate and sound encoding remain unchanged. --- src/radio/rmt/T330Encoder.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/radio/rmt/T330Encoder.cpp b/src/radio/rmt/T330Encoder.cpp index 59896daac..4e19da553 100644 --- a/src/radio/rmt/T330Encoder.cpp +++ b/src/radio/rmt/T330Encoder.cpp @@ -3,6 +3,7 @@ #include "radio/rmt/internal/Shared.h" #include +#include const rmt_data_t kRmtPreamble = {960, 1, 790, 0}; const rmt_data_t kRmtOne = {220, 1, 980, 0}; @@ -11,6 +12,13 @@ const rmt_data_t kRmtPostamble = {220, 1, 135, 0}; using namespace OpenShock; +struct ShockRollingState { + uint8_t counter = 0; + bool toggle = false; +}; + +static std::unordered_map s_shockState; + size_t Rmt::WellturnT330Encoder::GetBufferSize() { return 43; @@ -37,10 +45,23 @@ bool Rmt::WellturnT330Encoder::FillBuffer(rmt_data_t* sequence, uint16_t shocker return false; // Invalid type } + // Shock intensity byte: [toggle:1][counter:3][level:4] + // The collar reads shock intensity from the lower nibble (0-15), not as a raw 0-100 value. + uint8_t intensityByte = intensity; + if (type == ShockerCommandType::Shock) { + auto& state = s_shockState[shockerId]; + uint8_t level = (intensity * 15) / 100; + intensityByte = (static_cast(state.toggle) << 7) | ((state.counter & 0x7) << 4) | (level & 0xF); + state.toggle = !state.toggle; + if (!state.toggle) { + state.counter = (state.counter + 1) & 0x7; + } + } + uint8_t channelId = 0; // CH1 is 0b0000 and CH2 is 0b1110 on my remote but other values probably work. // Payload layout: [channelId:4][typeU:4][transmitterId:16][intensity:8][typeL:4][channelId:4] - uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensity) << 8) | (static_cast(typeVal & 0xF) << 4) + uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensityByte) << 8) | (static_cast(typeVal & 0xF) << 4) | static_cast(channelId & 0xF); // Shift the data left by 1 bit to append a zero From 84829ed3725eacd63993e92d6ec425e21f7698ca Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 27 May 2026 20:57:18 +0200 Subject: [PATCH 2/6] very bad impl of alternating the toggle bit --- include/radio/rmt/Sequence.h | 11 +++++++++++ src/radio/RFTransmitter.cpp | 2 +- src/radio/rmt/Sequence.cpp | 17 +++++++++++++++++ src/radio/rmt/T330Encoder.cpp | 29 ++++++++++++++++++++++------- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/include/radio/rmt/Sequence.h b/include/radio/rmt/Sequence.h index ba0432584..ea8ae17db 100644 --- a/include/radio/rmt/Sequence.h +++ b/include/radio/rmt/Sequence.h @@ -19,6 +19,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(0) , m_shockerId(0) , m_shockerModel() + , m_commandType() + , m_intensity(0) { } Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64_t transmitEnd); @@ -28,6 +30,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(other.m_transmitEnd) , m_shockerId(other.m_shockerId) , m_shockerModel(other.m_shockerModel) + , m_commandType(other.m_commandType) + , m_intensity(other.m_intensity) { other.reset(); } @@ -48,6 +52,7 @@ namespace OpenShock::Rmt { inline size_t size() const noexcept { return m_size; } bool fill(ShockerCommandType commandType, uint8_t intensity); + bool refill(); Sequence& operator=(Sequence&& other) { @@ -60,6 +65,8 @@ namespace OpenShock::Rmt { m_transmitEnd = other.m_transmitEnd; m_shockerId = other.m_shockerId; m_shockerModel = other.m_shockerModel; + m_commandType = other.m_commandType; + m_intensity = other.m_intensity; other.reset(); @@ -74,6 +81,8 @@ namespace OpenShock::Rmt { m_transmitEnd = 0; m_shockerId = 0; m_shockerModel = static_cast(0); + m_commandType = static_cast(0); + m_intensity = 0; } rmt_data_t* m_data; @@ -81,5 +90,7 @@ namespace OpenShock::Rmt { int64_t m_transmitEnd; uint16_t m_shockerId; ShockerModelType m_shockerModel; + ShockerCommandType m_commandType; + uint8_t m_intensity; }; } // namespace OpenShock::Rmt diff --git a/src/radio/RFTransmitter.cpp b/src/radio/RFTransmitter.cpp index 9f5de2833..50de338f2 100644 --- a/src/radio/RFTransmitter.cpp +++ b/src/radio/RFTransmitter.cpp @@ -180,7 +180,7 @@ static void writeSequences(rmt_obj_t* rmt_handle, std::vector& se int64_t timeToLive = seq->transmitEnd() - OpenShock::millis(); if (timeToLive > 0) { - // Send the command + seq->refill(); rmtWriteBlocking(rmt_handle, seq->payload(), seq->size()); } else { // Remove command if it has sent out its termination sequence for long enough diff --git a/src/radio/rmt/Sequence.cpp b/src/radio/rmt/Sequence.cpp index 9d952cc8d..0d8205745 100644 --- a/src/radio/rmt/Sequence.cpp +++ b/src/radio/rmt/Sequence.cpp @@ -73,5 +73,22 @@ Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64 bool Rmt::Sequence::fill(ShockerCommandType commandType, uint8_t intensity) { + m_commandType = commandType; + m_intensity = intensity; return fillSequenceImpl(payload(), m_shockerModel, m_shockerId, commandType, intensity); } + +static bool refillSequenceImpl(rmt_data_t* data, ShockerModelType modelType, uint16_t shockerId, ShockerCommandType commandType, uint8_t intensity) +{ + switch (modelType) { + case ShockerModelType::WellturnT330: + return Rmt::WellturnT330Encoder::FillBuffer(data, shockerId, commandType, intensity); + default: + return true; + } +} + +bool Rmt::Sequence::refill() +{ + return refillSequenceImpl(payload(), m_shockerModel, m_shockerId, m_commandType, m_intensity); +} diff --git a/src/radio/rmt/T330Encoder.cpp b/src/radio/rmt/T330Encoder.cpp index 4e19da553..1fde15810 100644 --- a/src/radio/rmt/T330Encoder.cpp +++ b/src/radio/rmt/T330Encoder.cpp @@ -1,5 +1,6 @@ #include "radio/rmt/T330Encoder.h" +#include "Core.h" #include "radio/rmt/internal/Shared.h" #include @@ -13,8 +14,9 @@ const rmt_data_t kRmtPostamble = {220, 1, 135, 0}; using namespace OpenShock; struct ShockRollingState { - uint8_t counter = 0; - bool toggle = false; + int64_t lastFillTime = 0; + int64_t transmitStart = 0; + uint8_t baseCounter = 0; }; static std::unordered_map s_shockState; @@ -46,16 +48,29 @@ bool Rmt::WellturnT330Encoder::FillBuffer(rmt_data_t* sequence, uint16_t shocker } // Shock intensity byte: [toggle:1][counter:3][level:4] - // The collar reads shock intensity from the lower nibble (0-15), not as a raw 0-100 value. + // Toggle flips every ~1s, counter increments every ~2s (each toggle cycle). + // Computed from wall-clock time so the buffer can be re-filled each send. uint8_t intensityByte = intensity; if (type == ShockerCommandType::Shock) { auto& state = s_shockState[shockerId]; uint8_t level = (intensity * 15) / 100; - intensityByte = (static_cast(state.toggle) << 7) | ((state.counter & 0x7) << 4) | (level & 0xF); - state.toggle = !state.toggle; - if (!state.toggle) { - state.counter = (state.counter + 1) & 0x7; + + int64_t now = OpenShock::millis(); + if (state.lastFillTime == 0 || (now - state.lastFillTime) > 200) { + if (state.lastFillTime != 0) { + state.baseCounter = (state.baseCounter + 1) & 0x7; + } + state.transmitStart = now; } + state.lastFillTime = now; + + int64_t elapsed = now - state.transmitStart; + int64_t seconds = elapsed / 1000; + + bool toggle = (seconds % 2) != 0; + uint8_t counter = (state.baseCounter + static_cast(seconds / 2)) & 0x7; + + intensityByte = (static_cast(toggle) << 7) | ((counter & 0x7) << 4) | (level & 0xF); } uint8_t channelId = 0; // CH1 is 0b0000 and CH2 is 0b1110 on my remote but other values probably work. From 567dad692f0017a020772d14940173dd7da00d78 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 27 May 2026 23:01:07 +0200 Subject: [PATCH 3/6] meh --- src/radio/rmt/T330Encoder.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/radio/rmt/T330Encoder.cpp b/src/radio/rmt/T330Encoder.cpp index 1fde15810..6e7e08c20 100644 --- a/src/radio/rmt/T330Encoder.cpp +++ b/src/radio/rmt/T330Encoder.cpp @@ -65,10 +65,10 @@ bool Rmt::WellturnT330Encoder::FillBuffer(rmt_data_t* sequence, uint16_t shocker state.lastFillTime = now; int64_t elapsed = now - state.transmitStart; - int64_t seconds = elapsed / 1000; + int64_t periods = elapsed / 1000; - bool toggle = (seconds % 2) != 0; - uint8_t counter = (state.baseCounter + static_cast(seconds / 2)) & 0x7; + bool toggle = (periods % 2) != 0; + uint8_t counter = (state.baseCounter + static_cast(periods / 2)) & 0x7; intensityByte = (static_cast(toggle) << 7) | ((counter & 0x7) << 4) | (level & 0xF); } From 735462aff5d80c830f0ea8a06025c52efd2f438f Mon Sep 17 00:00:00 2001 From: LucHeart Date: Thu, 8 Oct 2026 20:41:38 +0200 Subject: [PATCH 4/6] fix: encode T330 shock intensity as 4-bit level with rolling state --- .../src/radio/RFTransmitter.cpp | 2 +- components/protocols/CMakeLists.txt | 1 + .../protocols/include/radio/rmt/Sequence.h | 12 ++++++ components/protocols/src/Sequence.cpp | 16 ++++++++ components/protocols/src/T330Encoder.cpp | 38 ++++++++++++++++++- 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/components/device_control/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp index 303f5f342..c3491a0cc 100644 --- a/components/device_control/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -234,7 +234,7 @@ static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t en int64_t timeToLive = seq->transmitEnd() - now; if (timeToLive > 0) { - // Send the command + seq->refill(); transmitSymbols(channel, encoder, seq->payload(), seq->size()); ++seq; continue; diff --git a/components/protocols/CMakeLists.txt b/components/protocols/CMakeLists.txt index e1ccd0c60..fd39e49aa 100644 --- a/components/protocols/CMakeLists.txt +++ b/components/protocols/CMakeLists.txt @@ -20,4 +20,5 @@ idf_component_register( REQUIRES common # enums/*, OpenShock.h in public headers; also Checksum.h internally esp_driver_rmt # driver/rmt_types.h (rmt_symbol_word_t) in public headers PRIV_REQUIRES logging # OS_LOG* (internal) + temporal # OpenShock::millis() for T330 rolling state ) diff --git a/components/protocols/include/radio/rmt/Sequence.h b/components/protocols/include/radio/rmt/Sequence.h index 84e656074..88c3f3ce0 100644 --- a/components/protocols/include/radio/rmt/Sequence.h +++ b/components/protocols/include/radio/rmt/Sequence.h @@ -19,6 +19,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(0) , m_shockerId(0) , m_shockerModel() + , m_commandType() + , m_intensity(0) , m_terminatorSent(false) { } @@ -29,6 +31,8 @@ namespace OpenShock::Rmt { , m_transmitEnd(other.m_transmitEnd) , m_shockerId(other.m_shockerId) , m_shockerModel(other.m_shockerModel) + , m_commandType(other.m_commandType) + , m_intensity(other.m_intensity) , m_terminatorSent(other.m_terminatorSent) { other.reset(); @@ -54,6 +58,8 @@ namespace OpenShock::Rmt { inline size_t size() const noexcept { return m_size; } bool fill(ShockerCommandType commandType, uint8_t intensity); + // Re-encodes the payload from the last fill() for models whose frames change over time (T330 rolling state). + bool refill(); Sequence& operator=(Sequence&& other) { @@ -66,6 +72,8 @@ namespace OpenShock::Rmt { m_transmitEnd = other.m_transmitEnd; m_shockerId = other.m_shockerId; m_shockerModel = other.m_shockerModel; + m_commandType = other.m_commandType; + m_intensity = other.m_intensity; m_terminatorSent = other.m_terminatorSent; other.reset(); @@ -81,6 +89,8 @@ namespace OpenShock::Rmt { m_transmitEnd = 0; m_shockerId = 0; m_shockerModel = static_cast(0); + m_commandType = static_cast(0); + m_intensity = 0; m_terminatorSent = false; } @@ -89,6 +99,8 @@ namespace OpenShock::Rmt { int64_t m_transmitEnd; uint16_t m_shockerId; ShockerModelType m_shockerModel; + ShockerCommandType m_commandType; + uint8_t m_intensity; bool m_terminatorSent; }; } // namespace OpenShock::Rmt diff --git a/components/protocols/src/Sequence.cpp b/components/protocols/src/Sequence.cpp index 0b497147b..8c84b0ac3 100644 --- a/components/protocols/src/Sequence.cpp +++ b/components/protocols/src/Sequence.cpp @@ -53,6 +53,8 @@ Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64 , m_transmitEnd(transmitEnd) , m_shockerId(shockerId) , m_shockerModel(shockerModel) + , m_commandType() + , m_intensity(0) , m_terminatorSent(false) { const EncoderInfo* encoder = findEncoder(shockerModel); @@ -78,7 +80,21 @@ Rmt::Sequence::Sequence(ShockerModelType shockerModel, uint16_t shockerId, int64 bool Rmt::Sequence::fill(ShockerCommandType commandType, uint8_t intensity) { m_terminatorSent = false; // A new payload needs its own terminator again + m_commandType = commandType; + m_intensity = intensity; const EncoderInfo* encoder = findEncoder(m_shockerModel); return encoder != nullptr && m_data != nullptr && encoder->fill(payload(), m_shockerId, commandType, intensity); } + +bool Rmt::Sequence::refill() +{ + if (m_data == nullptr) return false; + + switch (m_shockerModel) { + case ShockerModelType::WellturnT330: + return Rmt::WellturnT330Encoder::FillBuffer(payload(), m_shockerId, m_commandType, m_intensity); + default: + return true; + } +} diff --git a/components/protocols/src/T330Encoder.cpp b/components/protocols/src/T330Encoder.cpp index 8c4481e8d..f71ab2176 100644 --- a/components/protocols/src/T330Encoder.cpp +++ b/components/protocols/src/T330Encoder.cpp @@ -1,8 +1,10 @@ #include "radio/rmt/T330Encoder.h" #include "radio/rmt/internal/Shared.h" +#include "Temporal.h" #include +#include const rmt_symbol_word_t kRmtPreamble = {960, 1, 790, 0}; const rmt_symbol_word_t kRmtOne = {220, 1, 980, 0}; @@ -11,6 +13,14 @@ const rmt_symbol_word_t kRmtPostamble = {220, 1, 135, 0}; using namespace OpenShock; +struct ShockRollingState { + int64_t lastFillTime = 0; + int64_t transmitStart = 0; + uint8_t baseCounter = 0; +}; + +static std::unordered_map s_shockState; + size_t Rmt::WellturnT330Encoder::GetBufferSize() { return 43; @@ -37,10 +47,36 @@ bool Rmt::WellturnT330Encoder::FillBuffer(rmt_symbol_word_t* sequence, uint16_t return false; // Invalid type } + // Shock intensity byte: [toggle:1][counter:3][level:4] + // Toggle flips every ~1s, counter increments every ~2s (each toggle cycle). + // Computed from wall-clock time so the buffer can be re-filled each send. + uint8_t intensityByte = intensity; + if (type == ShockerCommandType::Shock) { + auto& state = s_shockState[shockerId]; + uint8_t level = (intensity * 15) / 100; + + int64_t now = OpenShock::millis(); + if (state.lastFillTime == 0 || (now - state.lastFillTime) > 200) { + if (state.lastFillTime != 0) { + state.baseCounter = (state.baseCounter + 1) & 0x7; + } + state.transmitStart = now; + } + state.lastFillTime = now; + + int64_t elapsed = now - state.transmitStart; + int64_t periods = elapsed / 1000; + + bool toggle = (periods % 2) != 0; + uint8_t counter = (state.baseCounter + static_cast(periods / 2)) & 0x7; + + intensityByte = (static_cast(toggle) << 7) | ((counter & 0x7) << 4) | (level & 0xF); + } + uint8_t channelId = 0; // CH1 is 0b0000 and CH2 is 0b1110 on my remote but other values probably work. // Payload layout: [channelId:4][typeU:4][transmitterId:16][intensity:8][typeL:4][channelId:4] - uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensity) << 8) | (static_cast(typeVal & 0xF) << 4) + uint64_t data = (static_cast(channelId & 0xF) << 36) | (static_cast(typeVal & 0xF0) << 28) | (static_cast(shockerId) << 16) | (static_cast(intensityByte) << 8) | (static_cast(typeVal & 0xF) << 4) | static_cast(channelId & 0xF); // Shift the data left by 1 bit to append a zero From 6ae60f41b094fc9bfcc662942e45b74adbf8f3c1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 8 Oct 2026 20:42:50 +0200 Subject: [PATCH 5/6] test: cover T330 rolling shock intensity encoding --- components/protocols/host_test/CMakeLists.txt | 4 +- .../protocols/host_test/main/CMakeLists.txt | 2 + .../protocols/host_test/main/test_clock.h | 8 ++++ .../host_test/main/test_encoders.cpp | 37 ++++++++++++++++++- .../protocols/host_test/main/test_main.cpp | 13 +++++++ 5 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 components/protocols/host_test/main/test_clock.h diff --git a/components/protocols/host_test/CMakeLists.txt b/components/protocols/host_test/CMakeLists.txt index 7a0798656..4f4229c36 100644 --- a/components/protocols/host_test/CMakeLists.txt +++ b/components/protocols/host_test/CMakeLists.txt @@ -6,13 +6,15 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) # so no firmware component needs to be registered. set(COMPONENTS main) -# linux-target mocks: freertos (unity's IDF integration), and driver, whose include +# linux-target mocks: freertos (unity's IDF integration), esp_timer (Temporal.h's +# millis(), used by the T330 rolling state), and driver, whose include # dirs carry the real / (rmt_symbol_word_t) on # the linux target. Plus the shared host-test stand-ins (Logging.h, openshock_board.h, # CONFIG_OPENSHOCK_*). list(APPEND EXTRA_COMPONENT_DIRS "$ENV{IDF_PATH}/tools/mocks/freertos/" "$ENV{IDF_PATH}/tools/mocks/driver/" + "$ENV{IDF_PATH}/tools/mocks/esp_timer/" "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(protocols_host_test) diff --git a/components/protocols/host_test/main/CMakeLists.txt b/components/protocols/host_test/main/CMakeLists.txt index 2c2847fec..ba02de46e 100644 --- a/components/protocols/host_test/main/CMakeLists.txt +++ b/components/protocols/host_test/main/CMakeLists.txt @@ -18,8 +18,10 @@ idf_component_register( "../../include" # radio/rmt/*Encoder.h, Sequence.h "../../src" # radio/rmt/internal/Shared.h (EncodeBits) "../../../common/include" # enums/*, OpenShock.h, Checksum.h + "../../../temporal/include" # Temporal.h (esp_timer_get_time) REQUIRES unity host_support # Logging.h, openshock_board.h (test/host_support) driver # (tools/mocks/driver on linux) + esp_timer # tools/mocks/esp_timer WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped ) diff --git a/components/protocols/host_test/main/test_clock.h b/components/protocols/host_test/main/test_clock.h new file mode 100644 index 000000000..d8116ff37 --- /dev/null +++ b/components/protocols/host_test/main/test_clock.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +// Fake OpenShock::millis() for encoders with time-based state (T330 rolling counter). +namespace TestClock { + extern int64_t NowMs; +} // namespace TestClock diff --git a/components/protocols/host_test/main/test_encoders.cpp b/components/protocols/host_test/main/test_encoders.cpp index eb6c0c3a4..191d5b3fb 100644 --- a/components/protocols/host_test/main/test_encoders.cpp +++ b/components/protocols/host_test/main/test_encoders.cpp @@ -8,6 +8,7 @@ #include "radio/rmt/Petrainer998DREncoder.h" #include "radio/rmt/PetrainerEncoder.h" #include "radio/rmt/T330Encoder.h" +#include "test_clock.h" #include @@ -159,7 +160,7 @@ TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[p uint64_t data = decode(seq + 1, 41, t330One) >> 1; TEST_ASSERT_EQUAL_UINT8(0x6, (data >> 32) & 0xF); // 0b0110'0001 TEST_ASSERT_EQUAL_UINT8(0x1, (data >> 4) & 0xF); - TEST_ASSERT_EQUAL_UINT8(100, (data >> 8) & 0xFF); // clamped + TEST_ASSERT_EQUAL_UINT8(15, (data >> 8) & 0xF); // clamped to 100, scaled to the 4-bit level TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Sound, 50)); data = decode(seq + 1, 41, t330One) >> 1; @@ -170,6 +171,40 @@ TEST_CASE("Wellturn T330 shock/sound codes, clamping and unsupported types", "[p TEST_ASSERT_FALSE(Rmt::WellturnT330Encoder::FillBuffer(seq, 1, ShockerCommandType::Light, 10)); } +TEST_CASE("Wellturn T330 shock rolling toggle and counter", "[protocols][t330]") +{ + rmt_symbol_word_t seq[43]; + const uint16_t id = 0x1234; // own rolling state, independent of the other T330 tests + + auto shockByte = [&](uint8_t intensity) { + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, id, ShockerCommandType::Shock, intensity)); + return static_cast(((decode(seq + 1, 41, t330One) >> 1) >> 8) & 0xFF); + }; + + // Refill every 100ms like RFTransmitter does while a command is live. + auto runUntil = [&](int64_t from, int64_t to) { + for (TestClock::NowMs = from; TestClock::NowMs < to; TestClock::NowMs += 100) shockByte(50); + TestClock::NowMs = to; + }; + + // [toggle:1][counter:3][level:4] + TestClock::NowMs = 10000; + TEST_ASSERT_EQUAL_HEX8(0x07, shockByte(50)); // 50% -> level 7, fresh state + + runUntil(10100, 11000); + TEST_ASSERT_EQUAL_HEX8(0x87, shockByte(50)); // 1s in: toggle set + + runUntil(11100, 12000); + TEST_ASSERT_EQUAL_HEX8(0x17, shockByte(50)); // 2s in: toggle clear, counter 1 + + TestClock::NowMs = 20000; // gap > 200ms starts a new transmission: base counter advances + TEST_ASSERT_EQUAL_HEX8(0x1F, shockByte(100)); + + // Vibrate is not affected + TEST_ASSERT_TRUE(Rmt::WellturnT330Encoder::FillBuffer(seq, id, ShockerCommandType::Vibrate, 42)); + TEST_ASSERT_EQUAL_UINT8(42, ((decode(seq + 1, 41, t330One) >> 1) >> 8) & 0xFF); +} + // --------------------------------------------------------------------------- // D80: [preamble][0x04:8][shockerId:16][type:2][channel:2][intensity:4][checksum:8][postamble] // intensity is scaled 0-100 -> 0-15 (non-zero never rounds down to 0); channel = 1 diff --git a/components/protocols/host_test/main/test_main.cpp b/components/protocols/host_test/main/test_main.cpp index 7c6c46fcc..7e56369f9 100644 --- a/components/protocols/host_test/main/test_main.cpp +++ b/components/protocols/host_test/main/test_main.cpp @@ -2,8 +2,21 @@ // FreeRTOS is mocked, so there is no ESP startup - drive Unity directly. #include "unity.h" +#include "Mockesp_timer.h" + +#include "test_clock.h" + +int64_t TestClock::NowMs = 0; + +static int64_t fakeTimerGetTime(int) +{ + return TestClock::NowMs * 1000; +} + extern "C" int main(void) { + esp_timer_get_time_Stub(fakeTimerGetTime); + UNITY_BEGIN(); unity_run_all_tests(); return UNITY_END(); From ea68d05f628ff872a904c07eae03da711094edc3 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sun, 25 Oct 2026 00:44:05 +0200 Subject: [PATCH 6/6] fix: Address review feedback on WellturnT330Alt1 - Floor non-zero shock intensities at level 1, like D80, so 1-6% no longer encode as level 0. - Terminate a sequence instead of resending a stale frame when refill() fails, and log it. - Add the namespace comment clang-format wants on T330Alt1Encoder.h. --- components/device_control/src/radio/RFTransmitter.cpp | 8 +++++++- components/protocols/host_test/main/test_encoders.cpp | 5 ++++- components/protocols/include/radio/rmt/T330Alt1Encoder.h | 2 +- components/protocols/src/T330Alt1Encoder.cpp | 8 +++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/components/device_control/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp index 0ca3454b7..974432162 100644 --- a/components/device_control/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -233,9 +233,15 @@ static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t en int64_t timeToLive = seq->transmitEnd() - now; + // Models with rolling state re-encode every frame; if that fails, terminate rather than resend a stale frame. + if (timeToLive > 0 && !seq->refill(now)) { + OS_LOGE(TAG, "Failed to re-encode frame for shocker %hu, terminating it", seq->shockerId()); + seq->setTransmitEnd(now); + timeToLive = 0; + } + if (timeToLive > 0) { // Send the command - seq->refill(now); transmitSymbols(channel, encoder, seq->payload(), seq->size()); ++seq; continue; diff --git a/components/protocols/host_test/main/test_encoders.cpp b/components/protocols/host_test/main/test_encoders.cpp index 236117630..0eb629c09 100644 --- a/components/protocols/host_test/main/test_encoders.cpp +++ b/components/protocols/host_test/main/test_encoders.cpp @@ -195,7 +195,7 @@ TEST_CASE("Wellturn T330 Alt1 frame layout matches T330 for vibrate and sound", TEST_ASSERT_FALSE(Rmt::WellturnT330Alt1Encoder::FillBuffer(alt1, 1, ShockerCommandType::Light, 10)); } -TEST_CASE("Wellturn T330 Alt1 shock maps 0-100 to a 0-15 level", "[protocols][t330alt1]") +TEST_CASE("Wellturn T330 Alt1 shock maps 0-100 to a 0-15 level, non-zero never rounds to 0", "[protocols][t330alt1]") { rmt_symbol_word_t seq[43]; const struct { @@ -203,6 +203,9 @@ TEST_CASE("Wellturn T330 Alt1 shock maps 0-100 to a 0-15 level", "[protocols][t3 uint8_t level; } cases[] = { { 0, 0}, + { 1, 1}, + { 6, 1}, + { 14, 2}, { 50, 7}, {100, 15}, {150, 15}, // clamped diff --git a/components/protocols/include/radio/rmt/T330Alt1Encoder.h b/components/protocols/include/radio/rmt/T330Alt1Encoder.h index 95bed041d..9f145ce0b 100644 --- a/components/protocols/include/radio/rmt/T330Alt1Encoder.h +++ b/components/protocols/include/radio/rmt/T330Alt1Encoder.h @@ -11,4 +11,4 @@ namespace OpenShock::Rmt::WellturnT330Alt1Encoder { bool FillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity); // Re-encodes the frame with the shock rolling state (toggle + counter) for nowMs; call before every frame sent. bool RefillBuffer(rmt_symbol_word_t* data, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, int64_t nowMs); -} +} // namespace OpenShock::Rmt::WellturnT330Alt1Encoder diff --git a/components/protocols/src/T330Alt1Encoder.cpp b/components/protocols/src/T330Alt1Encoder.cpp index 14c8ee205..b5946a6b0 100644 --- a/components/protocols/src/T330Alt1Encoder.cpp +++ b/components/protocols/src/T330Alt1Encoder.cpp @@ -44,6 +44,12 @@ namespace { return (static_cast(toggle) << 7) | (counter << 4); } + // Shock reads a 0-15 level from the low nibble; like D80, a non-zero intensity never rounds down to level 0. + uint8_t shockLevel(uint8_t intensity) + { + return intensity > 0 ? std::max((intensity * 15) / 100, 1) : 0; + } + bool fillImpl(rmt_symbol_word_t* sequence, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, uint8_t rollingBits) { // Intensity must be between 0 and 100 @@ -53,7 +59,7 @@ namespace { switch (type) { case ShockerCommandType::Shock: typeVal = 0b01100001; - intensity = rollingBits | (((intensity * 15) / 100) & 0xF); // Shock reads a 0-15 level from the low nibble + intensity = rollingBits | shockLevel(intensity); break; case ShockerCommandType::Vibrate: typeVal = 0b01110010;